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
@@ -0,0 +1,234 @@
1
+ import { describe, it, expect, jest, beforeEach } from '@jest/globals';
2
+
3
+ // Mock OpenAI BEFORE importing
4
+ const mockCreate = jest.fn();
5
+ jest.mock('openai', () => {
6
+ const MockOpenAI = jest.fn().mockImplementation(() => ({
7
+ chat: { completions: { create: mockCreate } },
8
+ }));
9
+ return { __esModule: true, default: MockOpenAI };
10
+ });
11
+
12
+ jest.mock('../../src/config/config');
13
+ jest.mock('../../src/utils/logger');
14
+
15
+ import { duckDebateTool } from '../../src/tools/duck-debate';
16
+ import { ProviderManager } from '../../src/providers/manager';
17
+ import { ConfigManager } from '../../src/config/config';
18
+
19
+ describe('duckDebateTool structured JSON', () => {
20
+ let mockProviderManager: ProviderManager;
21
+
22
+ beforeEach(() => {
23
+ jest.clearAllMocks();
24
+
25
+ const mockConfigManager = {
26
+ getConfig: jest.fn().mockReturnValue({
27
+ providers: {
28
+ openai: {
29
+ api_key: 'key1',
30
+ base_url: 'https://api.openai.com/v1',
31
+ default_model: 'gpt-4',
32
+ nickname: 'GPT-4',
33
+ models: ['gpt-4'],
34
+ },
35
+ gemini: {
36
+ api_key: 'key2',
37
+ base_url: 'https://api.gemini.com/v1',
38
+ default_model: 'gemini-pro',
39
+ nickname: 'Gemini',
40
+ models: ['gemini-pro'],
41
+ },
42
+ },
43
+ default_provider: 'openai',
44
+ cache_ttl: 300,
45
+ enable_failover: true,
46
+ default_temperature: 0.7,
47
+ }),
48
+ } as unknown as jest.Mocked<ConfigManager>;
49
+
50
+ mockProviderManager = new ProviderManager(mockConfigManager);
51
+
52
+ const provider1 = mockProviderManager.getProvider('openai');
53
+ const provider2 = mockProviderManager.getProvider('gemini');
54
+ provider1['client'].chat.completions.create = mockCreate;
55
+ provider2['client'].chat.completions.create = mockCreate;
56
+ });
57
+
58
+ it('should return two content items: text and JSON', async () => {
59
+ // 1 round, 2 participants + synthesis = 3 calls
60
+ mockCreate
61
+ .mockResolvedValueOnce({
62
+ choices: [{ message: { content: 'Pro argument' }, finish_reason: 'stop' }],
63
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
64
+ model: 'gpt-4',
65
+ })
66
+ .mockResolvedValueOnce({
67
+ choices: [{ message: { content: 'Con argument' }, finish_reason: 'stop' }],
68
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
69
+ model: 'gemini-pro',
70
+ })
71
+ .mockResolvedValueOnce({
72
+ choices: [{ message: { content: 'Synthesis' }, finish_reason: 'stop' }],
73
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
74
+ model: 'gpt-4',
75
+ });
76
+
77
+ const result = await duckDebateTool(mockProviderManager, {
78
+ prompt: 'Test topic',
79
+ format: 'oxford',
80
+ rounds: 1,
81
+ });
82
+
83
+ expect(result.content).toHaveLength(2);
84
+ expect(result.content[0].type).toBe('text');
85
+ expect(result.content[1].type).toBe('text');
86
+ });
87
+
88
+ it('should include debate structure in JSON', async () => {
89
+ mockCreate
90
+ .mockResolvedValueOnce({
91
+ choices: [{ message: { content: 'Pro argument' }, finish_reason: 'stop' }],
92
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
93
+ model: 'gpt-4',
94
+ })
95
+ .mockResolvedValueOnce({
96
+ choices: [{ message: { content: 'Con argument' }, finish_reason: 'stop' }],
97
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
98
+ model: 'gemini-pro',
99
+ })
100
+ .mockResolvedValueOnce({
101
+ choices: [{ message: { content: 'Final synthesis' }, finish_reason: 'stop' }],
102
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
103
+ model: 'gpt-4',
104
+ });
105
+
106
+ const result = await duckDebateTool(mockProviderManager, {
107
+ prompt: 'Microservices vs monolith',
108
+ format: 'oxford',
109
+ rounds: 1,
110
+ });
111
+
112
+ const data = JSON.parse(result.content[1].text) as {
113
+ topic: string;
114
+ format: string;
115
+ totalRounds: number;
116
+ participants: { provider: string; nickname: string; position: string }[];
117
+ rounds: { round: number; provider: string; nickname: string; position: string; content: string }[][];
118
+ synthesis: string;
119
+ synthesizer: string;
120
+ };
121
+
122
+ expect(data.topic).toBe('Microservices vs monolith');
123
+ expect(data.format).toBe('oxford');
124
+ expect(data.totalRounds).toBe(1);
125
+ expect(data.participants).toHaveLength(2);
126
+ expect(data.participants[0].position).toBe('pro');
127
+ expect(data.participants[1].position).toBe('con');
128
+ expect(data.rounds).toHaveLength(1);
129
+ expect(data.rounds[0]).toHaveLength(2);
130
+ expect(data.rounds[0][0].content).toBe('Pro argument');
131
+ expect(data.rounds[0][1].content).toBe('Con argument');
132
+ expect(data.synthesis).toBe('Final synthesis');
133
+ expect(data.synthesizer).toBe('openai');
134
+ });
135
+
136
+ it('should assign adversarial positions correctly in JSON', async () => {
137
+ mockCreate
138
+ .mockResolvedValueOnce({
139
+ choices: [{ message: { content: 'Defense' }, finish_reason: 'stop' }],
140
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
141
+ model: 'gpt-4',
142
+ })
143
+ .mockResolvedValueOnce({
144
+ choices: [{ message: { content: 'Attack' }, finish_reason: 'stop' }],
145
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
146
+ model: 'gemini-pro',
147
+ })
148
+ .mockResolvedValueOnce({
149
+ choices: [{ message: { content: 'Synthesis' }, finish_reason: 'stop' }],
150
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
151
+ model: 'gpt-4',
152
+ });
153
+
154
+ const result = await duckDebateTool(mockProviderManager, {
155
+ prompt: 'Is AI dangerous?',
156
+ format: 'adversarial',
157
+ rounds: 1,
158
+ });
159
+
160
+ const data = JSON.parse(result.content[1].text) as {
161
+ format: string;
162
+ participants: { position: string }[];
163
+ };
164
+
165
+ expect(data.format).toBe('adversarial');
166
+ // First participant defends (pro), rest challenge (con)
167
+ expect(data.participants[0].position).toBe('pro');
168
+ expect(data.participants[1].position).toBe('con');
169
+ });
170
+
171
+ it('should preserve text content alongside JSON', async () => {
172
+ mockCreate
173
+ .mockResolvedValueOnce({
174
+ choices: [{ message: { content: 'Pro argument' }, finish_reason: 'stop' }],
175
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
176
+ model: 'gpt-4',
177
+ })
178
+ .mockResolvedValueOnce({
179
+ choices: [{ message: { content: 'Con argument' }, finish_reason: 'stop' }],
180
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
181
+ model: 'gemini-pro',
182
+ })
183
+ .mockResolvedValueOnce({
184
+ choices: [{ message: { content: 'Final synthesis' }, finish_reason: 'stop' }],
185
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
186
+ model: 'gpt-4',
187
+ });
188
+
189
+ const result = await duckDebateTool(mockProviderManager, {
190
+ prompt: 'Test topic',
191
+ format: 'oxford',
192
+ rounds: 1,
193
+ });
194
+
195
+ expect(result.content[0].text).toContain('Oxford Debate');
196
+ expect(result.content[0].text).toContain('Test topic');
197
+ expect(result.content[0].text).toContain('Synthesis');
198
+ expect(result.content[0].text).toContain('ROUND 1');
199
+ });
200
+
201
+ it('should assign socratic positions correctly in JSON', async () => {
202
+ mockCreate
203
+ .mockResolvedValueOnce({
204
+ choices: [{ message: { content: 'Question 1' }, finish_reason: 'stop' }],
205
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
206
+ model: 'gpt-4',
207
+ })
208
+ .mockResolvedValueOnce({
209
+ choices: [{ message: { content: 'Response 1' }, finish_reason: 'stop' }],
210
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
211
+ model: 'gemini-pro',
212
+ })
213
+ .mockResolvedValueOnce({
214
+ choices: [{ message: { content: 'Synthesis' }, finish_reason: 'stop' }],
215
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
216
+ model: 'gpt-4',
217
+ });
218
+
219
+ const result = await duckDebateTool(mockProviderManager, {
220
+ prompt: 'What is truth?',
221
+ format: 'socratic',
222
+ rounds: 1,
223
+ });
224
+
225
+ const data = JSON.parse(result.content[1].text) as {
226
+ format: string;
227
+ participants: { position: string }[];
228
+ };
229
+
230
+ expect(data.format).toBe('socratic');
231
+ expect(data.participants[0].position).toBe('neutral');
232
+ expect(data.participants[1].position).toBe('neutral');
233
+ });
234
+ });
@@ -0,0 +1,172 @@
1
+ import { describe, it, expect, jest, beforeEach } from '@jest/globals';
2
+
3
+ // Mock OpenAI BEFORE importing
4
+ const mockCreate = jest.fn();
5
+ jest.mock('openai', () => {
6
+ const MockOpenAI = jest.fn().mockImplementation(() => ({
7
+ chat: { completions: { create: mockCreate } },
8
+ }));
9
+ return { __esModule: true, default: MockOpenAI };
10
+ });
11
+
12
+ jest.mock('../../src/config/config');
13
+ jest.mock('../../src/utils/logger');
14
+
15
+ import { duckVoteTool } from '../../src/tools/duck-vote';
16
+ import { ProviderManager } from '../../src/providers/manager';
17
+ import { ConfigManager } from '../../src/config/config';
18
+
19
+ describe('duckVoteTool structured JSON', () => {
20
+ let mockProviderManager: ProviderManager;
21
+
22
+ beforeEach(() => {
23
+ jest.clearAllMocks();
24
+
25
+ const mockConfigManager = {
26
+ getConfig: jest.fn().mockReturnValue({
27
+ providers: {
28
+ openai: {
29
+ api_key: 'key1',
30
+ base_url: 'https://api.openai.com/v1',
31
+ default_model: 'gpt-4',
32
+ nickname: 'GPT-4',
33
+ models: ['gpt-4'],
34
+ },
35
+ gemini: {
36
+ api_key: 'key2',
37
+ base_url: 'https://api.gemini.com/v1',
38
+ default_model: 'gemini-pro',
39
+ nickname: 'Gemini',
40
+ models: ['gemini-pro'],
41
+ },
42
+ },
43
+ default_provider: 'openai',
44
+ cache_ttl: 300,
45
+ enable_failover: true,
46
+ default_temperature: 0.7,
47
+ }),
48
+ } as unknown as jest.Mocked<ConfigManager>;
49
+
50
+ mockProviderManager = new ProviderManager(mockConfigManager);
51
+
52
+ const provider1 = mockProviderManager.getProvider('openai');
53
+ const provider2 = mockProviderManager.getProvider('gemini');
54
+ provider1['client'].chat.completions.create = mockCreate;
55
+ provider2['client'].chat.completions.create = mockCreate;
56
+ });
57
+
58
+ it('should return two content items: text and JSON', async () => {
59
+ mockCreate
60
+ .mockResolvedValueOnce({
61
+ choices: [{ message: { content: '{"choice": "A", "confidence": 85, "reasoning": "Solid"}' }, finish_reason: 'stop' }],
62
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
63
+ model: 'gpt-4',
64
+ })
65
+ .mockResolvedValueOnce({
66
+ choices: [{ message: { content: '{"choice": "A", "confidence": 75, "reasoning": "Good"}' }, finish_reason: 'stop' }],
67
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
68
+ model: 'gemini-pro',
69
+ });
70
+
71
+ const result = await duckVoteTool(mockProviderManager, {
72
+ question: 'Best?',
73
+ options: ['A', 'B'],
74
+ });
75
+
76
+ expect(result.content).toHaveLength(2);
77
+ expect(result.content[0].type).toBe('text');
78
+ expect(result.content[1].type).toBe('text');
79
+ });
80
+
81
+ it('should include vote data in JSON', async () => {
82
+ mockCreate
83
+ .mockResolvedValueOnce({
84
+ choices: [{ message: { content: '{"choice": "A", "confidence": 85, "reasoning": "Best"}' }, finish_reason: 'stop' }],
85
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
86
+ model: 'gpt-4',
87
+ })
88
+ .mockResolvedValueOnce({
89
+ choices: [{ message: { content: '{"choice": "A", "confidence": 75, "reasoning": "Good"}' }, finish_reason: 'stop' }],
90
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
91
+ model: 'gemini-pro',
92
+ });
93
+
94
+ const result = await duckVoteTool(mockProviderManager, {
95
+ question: 'Best approach?',
96
+ options: ['A', 'B'],
97
+ });
98
+
99
+ const data = JSON.parse(result.content[1].text) as {
100
+ question: string;
101
+ options: string[];
102
+ winner: string | null;
103
+ isTie: boolean;
104
+ tally: Record<string, number>;
105
+ confidenceByOption: Record<string, number>;
106
+ votes: { voter: string; nickname: string; choice: string; confidence: number; reasoning: string }[];
107
+ totalVoters: number;
108
+ validVotes: number;
109
+ consensusLevel: string;
110
+ };
111
+
112
+ expect(data.question).toBe('Best approach?');
113
+ expect(data.options).toEqual(['A', 'B']);
114
+ expect(data.winner).toBe('A');
115
+ expect(data.consensusLevel).toBe('unanimous');
116
+ expect(data.tally['A']).toBe(2);
117
+ expect(data.tally['B']).toBe(0);
118
+ expect(data.votes).toHaveLength(2);
119
+ expect(data.votes[0].confidence).toBe(85);
120
+ });
121
+
122
+ it('should reflect tie in JSON data', async () => {
123
+ mockCreate
124
+ .mockResolvedValueOnce({
125
+ choices: [{ message: { content: '{"choice": "A", "confidence": 60}' }, finish_reason: 'stop' }],
126
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
127
+ model: 'gpt-4',
128
+ })
129
+ .mockResolvedValueOnce({
130
+ choices: [{ message: { content: '{"choice": "B", "confidence": 90}' }, finish_reason: 'stop' }],
131
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
132
+ model: 'gemini-pro',
133
+ });
134
+
135
+ const result = await duckVoteTool(mockProviderManager, {
136
+ question: 'Which?',
137
+ options: ['A', 'B'],
138
+ });
139
+
140
+ const data = JSON.parse(result.content[1].text) as {
141
+ isTie: boolean;
142
+ winner: string | null;
143
+ consensusLevel: string;
144
+ };
145
+
146
+ expect(data.isTie).toBe(true);
147
+ expect(data.consensusLevel).toBe('split');
148
+ // Tie-break by confidence: B has 90 vs A has 60
149
+ expect(data.winner).toBe('B');
150
+ });
151
+
152
+ it('should not include rawResponse in JSON votes', async () => {
153
+ mockCreate.mockResolvedValueOnce({
154
+ choices: [{ message: { content: '{"choice": "A", "confidence": 80}' }, finish_reason: 'stop' }],
155
+ usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
156
+ model: 'gpt-4',
157
+ });
158
+
159
+ const result = await duckVoteTool(mockProviderManager, {
160
+ question: 'Test?',
161
+ options: ['A', 'B'],
162
+ voters: ['openai'],
163
+ });
164
+
165
+ const data = JSON.parse(result.content[1].text) as {
166
+ votes: Record<string, unknown>[];
167
+ };
168
+
169
+ // rawResponse should not be in the structured output
170
+ expect(data.votes[0]).not.toHaveProperty('rawResponse');
171
+ });
172
+ });
@@ -62,9 +62,11 @@ describe('getUsageStatsTool', () => {
62
62
  const result = getUsageStatsTool(usageService, { period: 'today' });
63
63
 
64
64
  expect(result.content).toBeDefined();
65
- expect(result.content).toHaveLength(1);
65
+ expect(result.content).toHaveLength(2);
66
66
  expect(result.content[0].type).toBe('text');
67
67
  expect(typeof result.content[0].text).toBe('string');
68
+ expect(result.content[1].type).toBe('text');
69
+ expect(() => JSON.parse(result.content[1].text)).not.toThrow();
68
70
  });
69
71
 
70
72
  it('should include period label in output', () => {
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
2
+ import { getUsageStatsTool } from '../../src/tools/get-usage-stats.js';
3
+ import { UsageService } from '../../src/services/usage.js';
4
+ import { PricingService } from '../../src/services/pricing.js';
5
+ import { mkdtempSync, rmSync, existsSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { tmpdir } from 'os';
8
+
9
+ jest.mock('../../src/utils/logger');
10
+
11
+ describe('getUsageStatsTool structured JSON', () => {
12
+ let tempDir: string;
13
+ let pricingService: PricingService;
14
+ let usageService: UsageService;
15
+
16
+ beforeEach(() => {
17
+ tempDir = mkdtempSync(join(tmpdir(), 'usage-ui-test-'));
18
+ pricingService = new PricingService({
19
+ testprovider: {
20
+ 'test-model': { inputPricePerMillion: 5, outputPricePerMillion: 15 },
21
+ },
22
+ });
23
+ usageService = new UsageService(pricingService, {
24
+ dataDir: tempDir,
25
+ debounceMs: 0,
26
+ });
27
+ });
28
+
29
+ afterEach(() => {
30
+ usageService.shutdown();
31
+ if (existsSync(tempDir)) {
32
+ rmSync(tempDir, { recursive: true });
33
+ }
34
+ });
35
+
36
+ it('should return two content items: text and JSON', () => {
37
+ const result = getUsageStatsTool(usageService, { period: 'today' });
38
+
39
+ expect(result.content).toHaveLength(2);
40
+ expect(result.content[0].type).toBe('text');
41
+ expect(result.content[1].type).toBe('text');
42
+ expect(() => JSON.parse(result.content[1].text)).not.toThrow();
43
+ });
44
+
45
+ it('should include period and date range in JSON', () => {
46
+ const result = getUsageStatsTool(usageService, { period: '7d' });
47
+ const data = JSON.parse(result.content[1].text) as {
48
+ period: string;
49
+ startDate: string;
50
+ endDate: string;
51
+ };
52
+
53
+ expect(data.period).toBe('7d');
54
+ expect(data.startDate).toMatch(/\d{4}-\d{2}-\d{2}/);
55
+ expect(data.endDate).toMatch(/\d{4}-\d{2}-\d{2}/);
56
+ });
57
+
58
+ it('should include totals in JSON', () => {
59
+ usageService.recordUsage('openai', 'gpt-4o', 100, 50, false, false);
60
+
61
+ const result = getUsageStatsTool(usageService, { period: 'today' });
62
+ const data = JSON.parse(result.content[1].text) as {
63
+ totals: {
64
+ requests: number;
65
+ promptTokens: number;
66
+ completionTokens: number;
67
+ cacheHits: number;
68
+ errors: number;
69
+ };
70
+ };
71
+
72
+ expect(data.totals.requests).toBe(1);
73
+ expect(data.totals.promptTokens).toBe(100);
74
+ expect(data.totals.completionTokens).toBe(50);
75
+ expect(data.totals.cacheHits).toBe(0);
76
+ expect(data.totals.errors).toBe(0);
77
+ });
78
+
79
+ it('should include per-provider usage breakdown', () => {
80
+ usageService.recordUsage('openai', 'gpt-4o', 100, 50, false, false);
81
+ usageService.recordUsage('anthropic', 'claude-3', 200, 100, false, false);
82
+
83
+ const result = getUsageStatsTool(usageService, { period: 'today' });
84
+ const data = JSON.parse(result.content[1].text) as {
85
+ usage: Record<string, Record<string, { requests: number }>>;
86
+ };
87
+
88
+ expect(data.usage).toHaveProperty('openai');
89
+ expect(data.usage).toHaveProperty('anthropic');
90
+ expect(data.usage['openai']['gpt-4o'].requests).toBe(1);
91
+ expect(data.usage['anthropic']['claude-3'].requests).toBe(1);
92
+ });
93
+
94
+ it('should include cost data when pricing is configured', () => {
95
+ usageService.recordUsage('testprovider', 'test-model', 1000, 500, false, false);
96
+
97
+ const result = getUsageStatsTool(usageService, { period: 'today' });
98
+ const data = JSON.parse(result.content[1].text) as {
99
+ totals: { estimatedCostUSD?: number };
100
+ costByProvider?: Record<string, number>;
101
+ };
102
+
103
+ expect(data.totals.estimatedCostUSD).toBeDefined();
104
+ expect(typeof data.totals.estimatedCostUSD).toBe('number');
105
+ expect(data.costByProvider).toBeDefined();
106
+ expect(data.costByProvider!['testprovider']).toBeDefined();
107
+ });
108
+
109
+ it('should handle empty usage data in JSON', () => {
110
+ // No usage recorded — should still return valid JSON with empty usage
111
+ const result = getUsageStatsTool(usageService, { period: 'today' });
112
+ const data = JSON.parse(result.content[1].text) as {
113
+ totals: { requests: number };
114
+ usage: Record<string, unknown>;
115
+ };
116
+
117
+ expect(data.totals.requests).toBe(0);
118
+ expect(Object.keys(data.usage)).toHaveLength(0);
119
+ });
120
+
121
+ it('should preserve text content identical to before', () => {
122
+ usageService.recordUsage('openai', 'gpt-4o', 100, 50, false, false);
123
+
124
+ const result = getUsageStatsTool(usageService, { period: 'today' });
125
+
126
+ expect(result.content[0].text).toContain('Usage Statistics');
127
+ expect(result.content[0].text).toContain('TOTALS');
128
+ expect(result.content[0].text).toContain('openai');
129
+ });
130
+ });
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { readFileSync, existsSync } from 'fs';
3
+ import { join, dirname } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ const currentDir = dirname(fileURLToPath(import.meta.url));
7
+ const UI_DIR = join(currentDir, '..', 'dist', 'ui');
8
+
9
+ const UI_ENTRIES = [
10
+ 'compare-ducks',
11
+ 'duck-vote',
12
+ 'duck-debate',
13
+ 'usage-stats',
14
+ ];
15
+
16
+ describe('UI build output', () => {
17
+ for (const entry of UI_ENTRIES) {
18
+ describe(entry, () => {
19
+ const htmlPath = join(UI_DIR, entry, 'mcp-app.html');
20
+
21
+ it(`should have built ${entry}/mcp-app.html`, () => {
22
+ expect(existsSync(htmlPath)).toBe(true);
23
+ });
24
+
25
+ it('should be a valid single-file HTML bundle', () => {
26
+ if (!existsSync(htmlPath)) return;
27
+
28
+ const html = readFileSync(htmlPath, 'utf-8');
29
+
30
+ // Must be valid HTML
31
+ expect(html).toContain('<!DOCTYPE html>');
32
+ expect(html).toContain('<html');
33
+ expect(html).toContain('</html>');
34
+
35
+ // Must contain inlined script (no external src references for JS)
36
+ expect(html).toContain('<script');
37
+
38
+ // Must NOT have external script references (single-file)
39
+ expect(html).not.toMatch(/<script[^>]+src="[^"]+\.js"/);
40
+ });
41
+
42
+ it('should contain ext-apps App class usage', () => {
43
+ if (!existsSync(htmlPath)) return;
44
+
45
+ const html = readFileSync(htmlPath, 'utf-8');
46
+
47
+ // The bundled JS should contain App class instantiation
48
+ // (from @modelcontextprotocol/ext-apps)
49
+ expect(html).toContain('ontoolresult');
50
+ });
51
+ });
52
+ }
53
+ });
package/tsconfig.json CHANGED
@@ -22,5 +22,5 @@
22
22
  "isolatedModules": true
23
23
  },
24
24
  "include": ["src/**/*"],
25
- "exclude": ["node_modules", "dist", "tests"]
25
+ "exclude": ["node_modules", "dist", "tests", "src/ui"]
26
26
  }
package/vite.config.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { resolve } from 'path';
2
+ import { defineConfig } from 'vite';
3
+ import { viteSingleFile } from 'vite-plugin-singlefile';
4
+
5
+ // Build a single entry at a time. The build:ui script iterates over entries.
6
+ // Entry name is passed via VITE_UI_ENTRY env var.
7
+ const entry = process.env.VITE_UI_ENTRY || 'compare-ducks';
8
+
9
+ export default defineConfig({
10
+ root: resolve(__dirname, `src/ui/${entry}`),
11
+ plugins: [viteSingleFile()],
12
+ build: {
13
+ rollupOptions: {
14
+ input: resolve(__dirname, `src/ui/${entry}/mcp-app.html`),
15
+ },
16
+ outDir: resolve(__dirname, `dist/ui/${entry}`),
17
+ emptyOutDir: true,
18
+ },
19
+ });