mcp-rubber-duck 1.9.5 → 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.
- package/.eslintrc.json +3 -1
- package/CHANGELOG.md +12 -0
- package/README.md +54 -10
- package/assets/ext-apps-compare.png +0 -0
- package/assets/ext-apps-debate.png +0 -0
- package/assets/ext-apps-usage-stats.png +0 -0
- package/assets/ext-apps-vote.png +0 -0
- package/audit-ci.json +2 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +62 -4
- package/dist/server.js.map +1 -1
- package/dist/tools/compare-ducks.d.ts.map +1 -1
- package/dist/tools/compare-ducks.js +19 -0
- package/dist/tools/compare-ducks.js.map +1 -1
- package/dist/tools/duck-debate.d.ts.map +1 -1
- package/dist/tools/duck-debate.js +24 -0
- package/dist/tools/duck-debate.js.map +1 -1
- package/dist/tools/duck-vote.d.ts.map +1 -1
- package/dist/tools/duck-vote.js +23 -0
- package/dist/tools/duck-vote.js.map +1 -1
- package/dist/tools/get-usage-stats.d.ts.map +1 -1
- package/dist/tools/get-usage-stats.js +13 -0
- package/dist/tools/get-usage-stats.js.map +1 -1
- package/dist/ui/compare-ducks/mcp-app.html +187 -0
- package/dist/ui/duck-debate/mcp-app.html +182 -0
- package/dist/ui/duck-vote/mcp-app.html +168 -0
- package/dist/ui/usage-stats/mcp-app.html +192 -0
- package/jest.config.js +1 -0
- package/package.json +7 -3
- package/src/server.ts +79 -4
- package/src/tools/compare-ducks.ts +20 -0
- package/src/tools/duck-debate.ts +27 -0
- package/src/tools/duck-vote.ts +24 -0
- package/src/tools/get-usage-stats.ts +14 -0
- package/src/ui/compare-ducks/app.ts +88 -0
- package/src/ui/compare-ducks/mcp-app.html +102 -0
- package/src/ui/duck-debate/app.ts +111 -0
- package/src/ui/duck-debate/mcp-app.html +97 -0
- package/src/ui/duck-vote/app.ts +128 -0
- package/src/ui/duck-vote/mcp-app.html +83 -0
- package/src/ui/usage-stats/app.ts +156 -0
- package/src/ui/usage-stats/mcp-app.html +107 -0
- package/tests/duck-debate.test.ts +3 -1
- package/tests/duck-vote.test.ts +3 -1
- package/tests/tools/compare-ducks-ui.test.ts +135 -0
- package/tests/tools/compare-ducks.test.ts +3 -1
- package/tests/tools/duck-debate-ui.test.ts +234 -0
- package/tests/tools/duck-vote-ui.test.ts +172 -0
- package/tests/tools/get-usage-stats.test.ts +3 -1
- package/tests/tools/usage-stats-ui.test.ts +130 -0
- package/tests/ui-build.test.ts +53 -0
- package/tsconfig.json +1 -1
- package/vite.config.ts +19 -0
|
@@ -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
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
|
+
});
|