mcp-rubber-duck 1.5.0 โ†’ 1.5.2

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.
@@ -111,10 +111,39 @@ jobs:
111
111
  sarif_file: hadolint-results.sarif
112
112
  category: 'hadolint'
113
113
 
114
+ test:
115
+ name: ๐Ÿงช Test Suite
116
+ runs-on: ubuntu-latest
117
+
118
+ steps:
119
+ - name: ๐Ÿ“ฅ Checkout
120
+ uses: actions/checkout@v4
121
+
122
+ - name: ๐Ÿ“ฆ Setup Node.js
123
+ uses: actions/setup-node@v4
124
+ with:
125
+ node-version: '20'
126
+ cache: 'npm'
127
+
128
+ - name: ๐Ÿ“ฅ Install dependencies
129
+ run: npm ci
130
+
131
+ - name: ๐Ÿ” Lint
132
+ run: npm run lint
133
+
134
+ - name: ๐Ÿ—๏ธ Build
135
+ run: npm run build
136
+
137
+ - name: ๐Ÿ”ฌ Type check
138
+ run: npm run typecheck
139
+
140
+ - name: ๐Ÿงช Test
141
+ run: npm test
142
+
114
143
  quality-gates:
115
144
  name: ๐Ÿš€ Quality Gates
116
145
  runs-on: ubuntu-latest
117
- needs: [security-scan, dependency-check, dockerfile-lint]
146
+ needs: [security-scan, dependency-check, dockerfile-lint, test]
118
147
  if: always()
119
148
 
120
149
  steps:
@@ -141,5 +170,11 @@ jobs:
141
170
  echo "โŒ **Dockerfile Lint:** Failed" >> $GITHUB_STEP_SUMMARY
142
171
  fi
143
172
 
173
+ if [ "${{ needs.test.result }}" == "success" ]; then
174
+ echo "โœ… **Test Suite:** Passed" >> $GITHUB_STEP_SUMMARY
175
+ else
176
+ echo "โŒ **Test Suite:** Failed" >> $GITHUB_STEP_SUMMARY
177
+ fi
178
+
144
179
  echo "" >> $GITHUB_STEP_SUMMARY
145
180
  echo "๐Ÿ“‹ **View detailed results in the Security tab**" >> $GITHUB_STEP_SUMMARY
@@ -45,9 +45,8 @@ jobs:
45
45
  - name: ๐Ÿ”ฌ Type check
46
46
  run: npm run typecheck
47
47
 
48
- # Enable when tests are working
49
- # - name: ๐Ÿงช Test
50
- # run: npm test
48
+ - name: ๐Ÿงช Test
49
+ run: npm test
51
50
 
52
51
  security-scan:
53
52
  name: ๐Ÿ” Security Scan
package/.releaserc.json CHANGED
@@ -54,6 +54,10 @@
54
54
  "type": "test",
55
55
  "release": false
56
56
  },
57
+ {
58
+ "scope": "deps",
59
+ "release": "patch"
60
+ },
57
61
  {
58
62
  "type": "chore",
59
63
  "release": false
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [1.5.2](https://github.com/nesquikm/mcp-rubber-duck/compare/v1.5.1...v1.5.2) (2026-01-08)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **ci:** trigger patch releases for dependency updates ([b1c92c4](https://github.com/nesquikm/mcp-rubber-duck/commit/b1c92c4f56e4a8e67c307c28284b9f03256dc72f))
7
+
8
+ ## [1.5.1](https://github.com/nesquikm/mcp-rubber-duck/compare/v1.5.0...v1.5.1) (2026-01-02)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * update qs to resolve CVE (GHSA-6rw7-vpxm-498p) ([3c15c66](https://github.com/nesquikm/mcp-rubber-duck/commit/3c15c66a3c81c741e38d9cc22d8df5e6537ba7d9))
14
+
1
15
  # [1.5.0](https://github.com/nesquikm/mcp-rubber-duck/compare/v1.4.2...v1.5.0) (2025-12-08)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-rubber-duck",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "An MCP server that bridges to multiple OpenAI-compatible LLMs - your AI rubber duck debugging panel",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -0,0 +1,440 @@
1
+ import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
2
+ import { ApprovalService } from '../src/services/approval.js';
3
+
4
+ // Mock logger to avoid console noise during tests
5
+ jest.mock('../src/utils/logger');
6
+ jest.mock('../src/utils/safe-logger');
7
+
8
+ describe('ApprovalService', () => {
9
+ let service: ApprovalService;
10
+
11
+ beforeEach(() => {
12
+ jest.clearAllMocks();
13
+ jest.useFakeTimers();
14
+ service = new ApprovalService(60); // 60 second timeout for tests
15
+ });
16
+
17
+ afterEach(() => {
18
+ service.shutdown();
19
+ jest.useRealTimers();
20
+ });
21
+
22
+ describe('createApprovalRequest', () => {
23
+ it('should create a pending approval request', () => {
24
+ const request = service.createApprovalRequest(
25
+ 'TestDuck',
26
+ 'filesystem',
27
+ 'read_file',
28
+ { path: '/tmp/test.txt' }
29
+ );
30
+
31
+ expect(request.id).toBeDefined();
32
+ expect(request.duckName).toBe('TestDuck');
33
+ expect(request.mcpServer).toBe('filesystem');
34
+ expect(request.toolName).toBe('read_file');
35
+ expect(request.status).toBe('pending');
36
+ expect(request.arguments).toEqual({ path: '/tmp/test.txt' });
37
+ });
38
+
39
+ it('should set expiration time based on timeout', () => {
40
+ const before = Date.now();
41
+ const request = service.createApprovalRequest(
42
+ 'TestDuck',
43
+ 'filesystem',
44
+ 'read_file',
45
+ {}
46
+ );
47
+ const after = Date.now();
48
+
49
+ // 60 seconds timeout = 60000 ms
50
+ expect(request.expiresAt).toBeGreaterThanOrEqual(before + 60000);
51
+ expect(request.expiresAt).toBeLessThanOrEqual(after + 60000);
52
+ });
53
+
54
+ it('should generate unique IDs for each request', () => {
55
+ const request1 = service.createApprovalRequest('Duck1', 'server', 'tool', {});
56
+ const request2 = service.createApprovalRequest('Duck2', 'server', 'tool', {});
57
+
58
+ expect(request1.id).not.toBe(request2.id);
59
+ });
60
+ });
61
+
62
+ describe('getApprovalRequest', () => {
63
+ it('should return existing request', () => {
64
+ const created = service.createApprovalRequest('TestDuck', 'server', 'tool', {});
65
+ const retrieved = service.getApprovalRequest(created.id);
66
+
67
+ expect(retrieved).toBeDefined();
68
+ expect(retrieved?.id).toBe(created.id);
69
+ });
70
+
71
+ it('should return undefined for non-existent request', () => {
72
+ const retrieved = service.getApprovalRequest('non-existent-id');
73
+
74
+ expect(retrieved).toBeUndefined();
75
+ });
76
+
77
+ it('should mark expired requests when retrieved', () => {
78
+ const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {});
79
+
80
+ // Advance time past expiration
81
+ jest.advanceTimersByTime(61000);
82
+
83
+ const retrieved = service.getApprovalRequest(request.id);
84
+
85
+ expect(retrieved?.status).toBe('expired');
86
+ });
87
+ });
88
+
89
+ describe('getApprovalStatus', () => {
90
+ it('should return status of existing request', () => {
91
+ const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {});
92
+
93
+ expect(service.getApprovalStatus(request.id)).toBe('pending');
94
+ });
95
+
96
+ it('should return undefined for non-existent request', () => {
97
+ expect(service.getApprovalStatus('non-existent')).toBeUndefined();
98
+ });
99
+ });
100
+
101
+ describe('approveRequest', () => {
102
+ it('should approve pending request', () => {
103
+ const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {});
104
+
105
+ const result = service.approveRequest(request.id);
106
+
107
+ expect(result).toBe(true);
108
+ expect(service.getApprovalStatus(request.id)).toBe('approved');
109
+ });
110
+
111
+ it('should set approvedBy field', () => {
112
+ const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {});
113
+
114
+ service.approveRequest(request.id, 'admin');
115
+
116
+ const retrieved = service.getApprovalRequest(request.id);
117
+ expect(retrieved?.approvedBy).toBe('admin');
118
+ });
119
+
120
+ it('should default approvedBy to user', () => {
121
+ const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {});
122
+
123
+ service.approveRequest(request.id);
124
+
125
+ const retrieved = service.getApprovalRequest(request.id);
126
+ expect(retrieved?.approvedBy).toBe('user');
127
+ });
128
+
129
+ it('should return false for non-existent request', () => {
130
+ const result = service.approveRequest('non-existent');
131
+
132
+ expect(result).toBe(false);
133
+ });
134
+
135
+ it('should return false for already approved request', () => {
136
+ const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {});
137
+ service.approveRequest(request.id);
138
+
139
+ const result = service.approveRequest(request.id);
140
+
141
+ expect(result).toBe(false);
142
+ });
143
+
144
+ it('should return false for expired request', () => {
145
+ const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {});
146
+
147
+ // Advance time past expiration
148
+ jest.advanceTimersByTime(61000);
149
+
150
+ const result = service.approveRequest(request.id);
151
+
152
+ expect(result).toBe(false);
153
+ expect(service.getApprovalStatus(request.id)).toBe('expired');
154
+ });
155
+
156
+ it('should detect expiration via Date.now check even if status is still pending', () => {
157
+ const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {});
158
+
159
+ // Request is created with status 'pending'
160
+ expect(request.status).toBe('pending');
161
+
162
+ // Directly set expiresAt to be in the past (without triggering cleanup timer)
163
+ // This simulates the case where time has passed but cleanup hasn't run
164
+ request.expiresAt = Date.now() - 1000;
165
+
166
+ // Status is still 'pending' because cleanup timer hasn't run
167
+ expect(request.status).toBe('pending');
168
+
169
+ // approveRequest should detect expiration via Date.now() check
170
+ const result = service.approveRequest(request.id);
171
+
172
+ expect(result).toBe(false);
173
+ expect(request.status).toBe('expired');
174
+ });
175
+
176
+ it('should add tool to session approvals', () => {
177
+ const request = service.createApprovalRequest('TestDuck', 'filesystem', 'read_file', {});
178
+
179
+ service.approveRequest(request.id);
180
+
181
+ expect(service.isToolApprovedForSession('TestDuck', 'filesystem', 'read_file')).toBe(true);
182
+ });
183
+ });
184
+
185
+ describe('denyRequest', () => {
186
+ it('should deny pending request', () => {
187
+ const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {});
188
+
189
+ const result = service.denyRequest(request.id);
190
+
191
+ expect(result).toBe(true);
192
+ expect(service.getApprovalStatus(request.id)).toBe('denied');
193
+ });
194
+
195
+ it('should set denial reason when provided', () => {
196
+ const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {});
197
+
198
+ service.denyRequest(request.id, 'Security concern');
199
+
200
+ const retrieved = service.getApprovalRequest(request.id);
201
+ expect(retrieved?.deniedReason).toBe('Security concern');
202
+ });
203
+
204
+ it('should return false for non-existent request', () => {
205
+ const result = service.denyRequest('non-existent');
206
+
207
+ expect(result).toBe(false);
208
+ });
209
+
210
+ it('should return false for already denied request', () => {
211
+ const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {});
212
+ service.denyRequest(request.id);
213
+
214
+ const result = service.denyRequest(request.id);
215
+
216
+ expect(result).toBe(false);
217
+ });
218
+
219
+ it('should return false for approved request', () => {
220
+ const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {});
221
+ service.approveRequest(request.id);
222
+
223
+ const result = service.denyRequest(request.id);
224
+
225
+ expect(result).toBe(false);
226
+ });
227
+ });
228
+
229
+ describe('getPendingApprovals', () => {
230
+ it('should return only pending requests', () => {
231
+ service.createApprovalRequest('Duck1', 'server', 'tool', {});
232
+ const approved = service.createApprovalRequest('Duck2', 'server', 'tool', {});
233
+ service.approveRequest(approved.id);
234
+ const denied = service.createApprovalRequest('Duck3', 'server', 'tool', {});
235
+ service.denyRequest(denied.id);
236
+
237
+ const pending = service.getPendingApprovals();
238
+
239
+ expect(pending).toHaveLength(1);
240
+ expect(pending[0].duckName).toBe('Duck1');
241
+ });
242
+
243
+ it('should return empty array when no pending requests', () => {
244
+ const pending = service.getPendingApprovals();
245
+
246
+ expect(pending).toHaveLength(0);
247
+ });
248
+ });
249
+
250
+ describe('getAllApprovals', () => {
251
+ it('should return all requests', () => {
252
+ service.createApprovalRequest('Duck1', 'server', 'tool', {});
253
+ const approved = service.createApprovalRequest('Duck2', 'server', 'tool', {});
254
+ service.approveRequest(approved.id);
255
+ const denied = service.createApprovalRequest('Duck3', 'server', 'tool', {});
256
+ service.denyRequest(denied.id);
257
+
258
+ const all = service.getAllApprovals();
259
+
260
+ expect(all).toHaveLength(3);
261
+ });
262
+ });
263
+
264
+ describe('getApprovalsByDuck', () => {
265
+ it('should filter requests by duck name', () => {
266
+ service.createApprovalRequest('Duck1', 'server', 'tool1', {});
267
+ service.createApprovalRequest('Duck1', 'server', 'tool2', {});
268
+ service.createApprovalRequest('Duck2', 'server', 'tool3', {});
269
+
270
+ const duck1Requests = service.getApprovalsByDuck('Duck1');
271
+ const duck2Requests = service.getApprovalsByDuck('Duck2');
272
+ const nonExistent = service.getApprovalsByDuck('NonExistent');
273
+
274
+ expect(duck1Requests).toHaveLength(2);
275
+ expect(duck2Requests).toHaveLength(1);
276
+ expect(nonExistent).toHaveLength(0);
277
+ });
278
+ });
279
+
280
+ describe('cleanupExpired', () => {
281
+ it('should mark expired pending requests as expired', () => {
282
+ const request = service.createApprovalRequest('TestDuck', 'server', 'tool', {});
283
+
284
+ // Advance time past expiration
285
+ jest.advanceTimersByTime(61000);
286
+
287
+ const cleanedCount = service.cleanupExpired();
288
+
289
+ expect(cleanedCount).toBe(1);
290
+ expect(service.getApprovalStatus(request.id)).toBe('expired');
291
+ });
292
+
293
+ it('should not affect already approved/denied requests', () => {
294
+ const approved = service.createApprovalRequest('Duck1', 'server', 'tool', {});
295
+ service.approveRequest(approved.id);
296
+ const denied = service.createApprovalRequest('Duck2', 'server', 'tool', {});
297
+ service.denyRequest(denied.id);
298
+
299
+ // Advance time past expiration
300
+ jest.advanceTimersByTime(61000);
301
+
302
+ const cleanedCount = service.cleanupExpired();
303
+
304
+ expect(cleanedCount).toBe(0);
305
+ expect(service.getApprovalStatus(approved.id)).toBe('approved');
306
+ expect(service.getApprovalStatus(denied.id)).toBe('denied');
307
+ });
308
+
309
+ it('should be called by cleanup timer', () => {
310
+ service.createApprovalRequest('TestDuck', 'server', 'tool', {});
311
+
312
+ // Advance time past cleanup interval (60 seconds + expiration)
313
+ jest.advanceTimersByTime(121000);
314
+
315
+ const pending = service.getPendingApprovals();
316
+
317
+ expect(pending).toHaveLength(0);
318
+ });
319
+ });
320
+
321
+ describe('session approvals', () => {
322
+ it('should track tool approvals for session', () => {
323
+ expect(service.isToolApprovedForSession('Duck', 'server', 'tool')).toBe(false);
324
+
325
+ service.markToolAsApprovedForSession('Duck', 'server', 'tool');
326
+
327
+ expect(service.isToolApprovedForSession('Duck', 'server', 'tool')).toBe(true);
328
+ });
329
+
330
+ it('should differentiate between duck/server/tool combinations', () => {
331
+ service.markToolAsApprovedForSession('Duck1', 'server', 'tool');
332
+
333
+ expect(service.isToolApprovedForSession('Duck1', 'server', 'tool')).toBe(true);
334
+ expect(service.isToolApprovedForSession('Duck2', 'server', 'tool')).toBe(false);
335
+ expect(service.isToolApprovedForSession('Duck1', 'other', 'tool')).toBe(false);
336
+ expect(service.isToolApprovedForSession('Duck1', 'server', 'other')).toBe(false);
337
+ });
338
+
339
+ it('should clear session approvals', () => {
340
+ service.markToolAsApprovedForSession('Duck1', 'server', 'tool1');
341
+ service.markToolAsApprovedForSession('Duck2', 'server', 'tool2');
342
+
343
+ service.clearSessionApprovals();
344
+
345
+ expect(service.isToolApprovedForSession('Duck1', 'server', 'tool1')).toBe(false);
346
+ expect(service.isToolApprovedForSession('Duck2', 'server', 'tool2')).toBe(false);
347
+ });
348
+
349
+ it('should get all session approvals', () => {
350
+ service.markToolAsApprovedForSession('Duck1', 'server', 'tool1');
351
+ service.markToolAsApprovedForSession('Duck2', 'server', 'tool2');
352
+
353
+ const approvals = service.getSessionApprovals();
354
+
355
+ expect(approvals).toHaveLength(2);
356
+ expect(approvals).toContain('Duck1:server:tool1');
357
+ expect(approvals).toContain('Duck2:server:tool2');
358
+ });
359
+ });
360
+
361
+ describe('getStats', () => {
362
+ it('should return accurate statistics', () => {
363
+ service.createApprovalRequest('Duck1', 'server', 'tool', {});
364
+ const approved = service.createApprovalRequest('Duck2', 'server', 'tool', {});
365
+ service.approveRequest(approved.id);
366
+ const denied = service.createApprovalRequest('Duck3', 'server', 'tool', {});
367
+ service.denyRequest(denied.id);
368
+ const expiring = service.createApprovalRequest('Duck4', 'server', 'tool', {});
369
+
370
+ // Expire one request
371
+ jest.advanceTimersByTime(61000);
372
+ service.getApprovalRequest(expiring.id); // Trigger expiration check
373
+
374
+ const stats = service.getStats();
375
+
376
+ expect(stats.total).toBe(4);
377
+ expect(stats.pending).toBe(0); // The first one also expired since time advanced
378
+ expect(stats.approved).toBe(1);
379
+ expect(stats.denied).toBe(1);
380
+ expect(stats.expired).toBe(2);
381
+ });
382
+
383
+ it('should return zeros when no requests exist', () => {
384
+ const stats = service.getStats();
385
+
386
+ expect(stats.total).toBe(0);
387
+ expect(stats.pending).toBe(0);
388
+ expect(stats.approved).toBe(0);
389
+ expect(stats.denied).toBe(0);
390
+ expect(stats.expired).toBe(0);
391
+ });
392
+ });
393
+
394
+ describe('shutdown', () => {
395
+ it('should stop cleanup timer', () => {
396
+ service.shutdown();
397
+
398
+ // Should not throw when advancing timers after shutdown
399
+ expect(() => jest.advanceTimersByTime(120000)).not.toThrow();
400
+ });
401
+
402
+ it('should be safe to call multiple times', () => {
403
+ service.shutdown();
404
+ expect(() => service.shutdown()).not.toThrow();
405
+ });
406
+ });
407
+
408
+ describe('custom timeout', () => {
409
+ it('should use custom timeout value', () => {
410
+ const customService = new ApprovalService(30); // 30 seconds
411
+
412
+ const request = customService.createApprovalRequest('Duck', 'server', 'tool', {});
413
+
414
+ // After 25 seconds, should still be pending
415
+ jest.advanceTimersByTime(25000);
416
+ expect(customService.getApprovalStatus(request.id)).toBe('pending');
417
+
418
+ // After 31 seconds total, should be expired
419
+ jest.advanceTimersByTime(6000);
420
+ expect(customService.getApprovalStatus(request.id)).toBe('expired');
421
+
422
+ customService.shutdown();
423
+ });
424
+
425
+ it('should use default timeout when not specified', () => {
426
+ const defaultService = new ApprovalService();
427
+
428
+ const request = defaultService.createApprovalRequest('Duck', 'server', 'tool', {});
429
+
430
+ // Default is 300 seconds (5 minutes)
431
+ jest.advanceTimersByTime(299000);
432
+ expect(defaultService.getApprovalStatus(request.id)).toBe('pending');
433
+
434
+ jest.advanceTimersByTime(2000);
435
+ expect(defaultService.getApprovalStatus(request.id)).toBe('expired');
436
+
437
+ defaultService.shutdown();
438
+ });
439
+ });
440
+ });