mcp-rubber-duck 1.5.1 → 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.
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,10 @@
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
+
1
8
  ## [1.5.1](https://github.com/nesquikm/mcp-rubber-duck/compare/v1.5.0...v1.5.1) (2026-01-02)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-rubber-duck",
3
- "version": "1.5.1",
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
+ });