mcp-rubber-duck 1.5.1 → 1.6.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 (69) hide show
  1. package/.claude/agents/pricing-updater.md +111 -0
  2. package/.claude/commands/update-pricing.md +22 -0
  3. package/.releaserc.json +4 -0
  4. package/CHANGELOG.md +14 -0
  5. package/dist/config/types.d.ts +72 -0
  6. package/dist/config/types.d.ts.map +1 -1
  7. package/dist/config/types.js +8 -0
  8. package/dist/config/types.js.map +1 -1
  9. package/dist/data/default-pricing.d.ts +18 -0
  10. package/dist/data/default-pricing.d.ts.map +1 -0
  11. package/dist/data/default-pricing.js +307 -0
  12. package/dist/data/default-pricing.js.map +1 -0
  13. package/dist/providers/enhanced-manager.d.ts +2 -1
  14. package/dist/providers/enhanced-manager.d.ts.map +1 -1
  15. package/dist/providers/enhanced-manager.js +20 -2
  16. package/dist/providers/enhanced-manager.js.map +1 -1
  17. package/dist/providers/manager.d.ts +3 -1
  18. package/dist/providers/manager.d.ts.map +1 -1
  19. package/dist/providers/manager.js +12 -1
  20. package/dist/providers/manager.js.map +1 -1
  21. package/dist/server.d.ts +2 -0
  22. package/dist/server.d.ts.map +1 -1
  23. package/dist/server.js +35 -4
  24. package/dist/server.js.map +1 -1
  25. package/dist/services/pricing.d.ts +56 -0
  26. package/dist/services/pricing.d.ts.map +1 -0
  27. package/dist/services/pricing.js +124 -0
  28. package/dist/services/pricing.js.map +1 -0
  29. package/dist/services/usage.d.ts +48 -0
  30. package/dist/services/usage.d.ts.map +1 -0
  31. package/dist/services/usage.js +243 -0
  32. package/dist/services/usage.js.map +1 -0
  33. package/dist/tools/get-usage-stats.d.ts +8 -0
  34. package/dist/tools/get-usage-stats.d.ts.map +1 -0
  35. package/dist/tools/get-usage-stats.js +92 -0
  36. package/dist/tools/get-usage-stats.js.map +1 -0
  37. package/package.json +1 -1
  38. package/src/config/types.ts +51 -0
  39. package/src/data/default-pricing.ts +368 -0
  40. package/src/providers/enhanced-manager.ts +41 -4
  41. package/src/providers/manager.ts +22 -1
  42. package/src/server.ts +42 -4
  43. package/src/services/pricing.ts +155 -0
  44. package/src/services/usage.ts +293 -0
  45. package/src/tools/get-usage-stats.ts +109 -0
  46. package/tests/approval.test.ts +440 -0
  47. package/tests/cache.test.ts +240 -0
  48. package/tests/config.test.ts +468 -0
  49. package/tests/consensus.test.ts +10 -0
  50. package/tests/conversation.test.ts +86 -0
  51. package/tests/duck-debate.test.ts +105 -1
  52. package/tests/duck-iterate.test.ts +30 -0
  53. package/tests/duck-judge.test.ts +93 -0
  54. package/tests/duck-vote.test.ts +46 -0
  55. package/tests/health.test.ts +129 -0
  56. package/tests/pricing.test.ts +335 -0
  57. package/tests/providers.test.ts +591 -0
  58. package/tests/safe-logger.test.ts +314 -0
  59. package/tests/tools/approve-mcp-request.test.ts +239 -0
  60. package/tests/tools/ask-duck.test.ts +159 -0
  61. package/tests/tools/chat-duck.test.ts +191 -0
  62. package/tests/tools/compare-ducks.test.ts +190 -0
  63. package/tests/tools/duck-council.test.ts +219 -0
  64. package/tests/tools/get-pending-approvals.test.ts +195 -0
  65. package/tests/tools/get-usage-stats.test.ts +236 -0
  66. package/tests/tools/list-ducks.test.ts +144 -0
  67. package/tests/tools/list-models.test.ts +163 -0
  68. package/tests/tools/mcp-status.test.ts +330 -0
  69. package/tests/usage.test.ts +661 -0
@@ -0,0 +1,314 @@
1
+ import { describe, it, expect, jest, beforeEach } from '@jest/globals';
2
+ import { SafeLogger, sanitizeObject } from '../src/utils/safe-logger.js';
3
+ import { logger } from '../src/utils/logger.js';
4
+
5
+ // Mock the logger module before it's used
6
+ jest.mock('../src/utils/logger', () => ({
7
+ logger: {
8
+ debug: jest.fn(),
9
+ info: jest.fn(),
10
+ warn: jest.fn(),
11
+ error: jest.fn(),
12
+ },
13
+ }));
14
+
15
+ describe('sanitizeObject', () => {
16
+ describe('primitive types', () => {
17
+ it('should return null as-is', () => {
18
+ expect(sanitizeObject(null)).toBe(null);
19
+ });
20
+
21
+ it('should return undefined as-is', () => {
22
+ expect(sanitizeObject(undefined)).toBe(undefined);
23
+ });
24
+
25
+ it('should return numbers as-is', () => {
26
+ expect(sanitizeObject(42)).toBe(42);
27
+ expect(sanitizeObject(0)).toBe(0);
28
+ expect(sanitizeObject(-1.5)).toBe(-1.5);
29
+ });
30
+
31
+ it('should return booleans as-is', () => {
32
+ expect(sanitizeObject(true)).toBe(true);
33
+ expect(sanitizeObject(false)).toBe(false);
34
+ });
35
+
36
+ it('should return regular strings as-is', () => {
37
+ expect(sanitizeObject('hello world')).toBe('hello world');
38
+ expect(sanitizeObject('')).toBe('');
39
+ });
40
+
41
+ it('should redact long base64-like strings', () => {
42
+ // Pattern matches: alphanumeric + base64 chars (+/=), length > 20
43
+ const sensitiveString = 'abcdefghijklmnopqrstuvwxyz123456789ABCD';
44
+ const result = sanitizeObject(sensitiveString);
45
+ expect(result).toMatch(/\[REDACTED:\d+chars\]/);
46
+ });
47
+
48
+ it('should not redact normal long strings', () => {
49
+ const normalLongString = 'This is a normal sentence that is longer than 20 characters.';
50
+ expect(sanitizeObject(normalLongString)).toBe(normalLongString);
51
+ });
52
+ });
53
+
54
+ describe('arrays', () => {
55
+ it('should recursively sanitize array elements', () => {
56
+ const input = ['hello', { apiKey: 'secret123' }, 42];
57
+ const result = sanitizeObject(input) as unknown[];
58
+
59
+ expect(result[0]).toBe('hello');
60
+ expect((result[1] as Record<string, unknown>).apiKey).toMatch(/\[REDACTED/);
61
+ expect(result[2]).toBe(42);
62
+ });
63
+
64
+ it('should handle empty arrays', () => {
65
+ expect(sanitizeObject([])).toEqual([]);
66
+ });
67
+
68
+ it('should handle nested arrays', () => {
69
+ const input = [[{ password: 'secret' }]];
70
+ const result = sanitizeObject(input) as unknown[][];
71
+
72
+ expect((result[0][0] as Record<string, unknown>).password).toMatch(/\[REDACTED/);
73
+ });
74
+ });
75
+
76
+ describe('objects', () => {
77
+ it('should redact sensitive fields', () => {
78
+ const sensitiveFields = [
79
+ 'password',
80
+ 'apiKey',
81
+ 'api_key',
82
+ 'token',
83
+ 'secret',
84
+ 'auth',
85
+ 'authorization',
86
+ 'cookie',
87
+ 'session',
88
+ 'private_key',
89
+ 'privateKey',
90
+ 'client_secret',
91
+ 'clientSecret',
92
+ ];
93
+
94
+ for (const field of sensitiveFields) {
95
+ const input = { [field]: 'sensitive_value' };
96
+ const result = sanitizeObject(input) as Record<string, unknown>;
97
+ expect(result[field]).toMatch(/\[REDACTED/);
98
+ }
99
+ });
100
+
101
+ it('should redact fields matching patterns', () => {
102
+ const input = {
103
+ userPassword: 'secret',
104
+ awsSecretAccessKey: 'key123',
105
+ authToken: 'tok123',
106
+ sessionCookie: 'cookie123',
107
+ };
108
+
109
+ const result = sanitizeObject(input) as Record<string, unknown>;
110
+
111
+ expect(result.userPassword).toMatch(/\[REDACTED/);
112
+ expect(result.awsSecretAccessKey).toMatch(/\[REDACTED/);
113
+ expect(result.authToken).toMatch(/\[REDACTED/);
114
+ expect(result.sessionCookie).toMatch(/\[REDACTED/);
115
+ });
116
+
117
+ it('should not redact non-sensitive fields', () => {
118
+ const input = {
119
+ username: 'john',
120
+ email: 'john@example.com',
121
+ id: 123,
122
+ };
123
+
124
+ const result = sanitizeObject(input) as Record<string, unknown>;
125
+
126
+ expect(result.username).toBe('john');
127
+ expect(result.email).toBe('john@example.com');
128
+ expect(result.id).toBe(123);
129
+ });
130
+
131
+ it('should handle nested objects', () => {
132
+ const input = {
133
+ user: {
134
+ credentials: {
135
+ password: 'secret',
136
+ },
137
+ },
138
+ };
139
+
140
+ const result = sanitizeObject(input) as Record<string, unknown>;
141
+ const nested = (result.user as Record<string, unknown>).credentials as Record<string, unknown>;
142
+
143
+ expect(nested.password).toMatch(/\[REDACTED/);
144
+ });
145
+
146
+ it('should include length for string sensitive values', () => {
147
+ const input = { password: 'secret123' };
148
+ const result = sanitizeObject(input) as Record<string, unknown>;
149
+
150
+ expect(result.password).toBe('[REDACTED:9chars]');
151
+ });
152
+
153
+ it('should use simple REDACTED for non-string sensitive values', () => {
154
+ const input = { password: 12345, apiKey: null, token: undefined };
155
+ const result = sanitizeObject(input) as Record<string, unknown>;
156
+
157
+ expect(result.password).toBe('[REDACTED]');
158
+ expect(result.apiKey).toBe('[REDACTED]');
159
+ expect(result.token).toBe('[REDACTED]');
160
+ });
161
+
162
+ it('should use simple REDACTED for empty string sensitive values', () => {
163
+ const input = { password: '' };
164
+ const result = sanitizeObject(input) as Record<string, unknown>;
165
+
166
+ expect(result.password).toBe('[REDACTED]');
167
+ });
168
+ });
169
+
170
+ describe('max depth handling', () => {
171
+ it('should stop at max depth and return placeholder', () => {
172
+ const deepObject = {
173
+ l1: { l2: { l3: { l4: { l5: { l6: 'too deep' } } } } },
174
+ };
175
+
176
+ const result = sanitizeObject(deepObject) as Record<string, unknown>;
177
+ const l5 = (
178
+ (((result.l1 as Record<string, unknown>).l2 as Record<string, unknown>).l3 as Record<string, unknown>)
179
+ .l4 as Record<string, unknown>
180
+ ).l5 as string;
181
+
182
+ expect(l5).toBe('[Max depth exceeded]');
183
+ });
184
+
185
+ it('should respect custom max depth', () => {
186
+ const object = { l1: { l2: { l3: 'value' } } };
187
+
188
+ const result = sanitizeObject(object, 2) as Record<string, unknown>;
189
+ const l2 = (result.l1 as Record<string, unknown>).l2 as string;
190
+
191
+ expect(l2).toBe('[Max depth exceeded]');
192
+ });
193
+ });
194
+
195
+ describe('edge cases', () => {
196
+ it('should handle functions (return as-is)', () => {
197
+ const fn = () => 'test';
198
+ expect(sanitizeObject(fn)).toBe(fn);
199
+ });
200
+
201
+ it('should handle symbols (return as-is)', () => {
202
+ const sym = Symbol('test');
203
+ expect(sanitizeObject(sym)).toBe(sym);
204
+ });
205
+ });
206
+ });
207
+
208
+ describe('SafeLogger', () => {
209
+ // The SafeLogger methods internally call the logger and sanitizeObject
210
+ // We test the sanitization logic separately; here we just ensure the methods don't throw
211
+
212
+ describe('logging methods', () => {
213
+ it('should call debug without throwing', () => {
214
+ expect(() => SafeLogger.debug('test message')).not.toThrow();
215
+ expect(() => SafeLogger.debug('test', { data: 'value' })).not.toThrow();
216
+ });
217
+
218
+ it('should call info without throwing', () => {
219
+ expect(() => SafeLogger.info('info message')).not.toThrow();
220
+ expect(() => SafeLogger.info('info', { data: 'value' })).not.toThrow();
221
+ });
222
+
223
+ it('should call warn without throwing', () => {
224
+ expect(() => SafeLogger.warn('warning message')).not.toThrow();
225
+ expect(() => SafeLogger.warn('warning', { data: 'value' })).not.toThrow();
226
+ });
227
+
228
+ it('should call error without throwing', () => {
229
+ expect(() => SafeLogger.error('error message')).not.toThrow();
230
+ expect(() => SafeLogger.error('error', { data: 'value' })).not.toThrow();
231
+ });
232
+ });
233
+ });
234
+
235
+ describe('SafeLogger.sanitizeToolArgs', () => {
236
+ it('should sanitize regular sensitive fields', () => {
237
+ const args = { password: 'secret', query: 'SELECT *' };
238
+ const result = SafeLogger.sanitizeToolArgs(args) as Record<string, unknown>;
239
+
240
+ expect(result.password).toMatch(/\[REDACTED/);
241
+ expect(result.query).toBe('SELECT *');
242
+ });
243
+
244
+ it('should sanitize Unix file paths with usernames', () => {
245
+ const args = { path: '/Users/johndoe/Documents/file.txt' };
246
+ const result = SafeLogger.sanitizeToolArgs(args) as Record<string, unknown>;
247
+
248
+ expect(result.path).toBe('/Users/[USER]/Documents/file.txt');
249
+ });
250
+
251
+ it('should sanitize Windows file paths with usernames', () => {
252
+ const args = { path: '\\Users\\johndoe\\Documents\\file.txt' };
253
+ const result = SafeLogger.sanitizeToolArgs(args) as Record<string, unknown>;
254
+
255
+ expect(result.path).toBe('\\Users\\[USER]\\Documents\\file.txt');
256
+ });
257
+
258
+ it('should sanitize URLs with credentials', () => {
259
+ const args = { url: 'https://admin:password123@api.example.com/data' };
260
+ const result = SafeLogger.sanitizeToolArgs(args) as Record<string, unknown>;
261
+
262
+ expect(result.url).toBe('https://[USER]:[REDACTED]@api.example.com/data');
263
+ });
264
+
265
+ it('should handle URLs without credentials', () => {
266
+ const args = { url: 'https://api.example.com/data' };
267
+ const result = SafeLogger.sanitizeToolArgs(args) as Record<string, unknown>;
268
+
269
+ expect(result.url).toBe('https://api.example.com/data');
270
+ });
271
+
272
+ it('should handle non-object input', () => {
273
+ expect(SafeLogger.sanitizeToolArgs('string')).toBe('string');
274
+ expect(SafeLogger.sanitizeToolArgs(123)).toBe(123);
275
+ expect(SafeLogger.sanitizeToolArgs(null)).toBe(null);
276
+ });
277
+
278
+ it('should handle object without path or url', () => {
279
+ const args = { query: 'SELECT * FROM users' };
280
+ const result = SafeLogger.sanitizeToolArgs(args) as Record<string, unknown>;
281
+
282
+ expect(result.query).toBe('SELECT * FROM users');
283
+ });
284
+ });
285
+
286
+ describe('SafeLogger.createApprovalMessage', () => {
287
+ it('should create approval message with sanitized args', () => {
288
+ const message = SafeLogger.createApprovalMessage(
289
+ 'TestDuck',
290
+ 'filesystem',
291
+ 'read_file',
292
+ { path: '/Users/john/document.txt', password: 'mysecretpassword' }
293
+ );
294
+
295
+ expect(message).toContain('TestDuck');
296
+ expect(message).toContain('filesystem:read_file');
297
+ expect(message).toContain('[USER]');
298
+ expect(message).toContain('[REDACTED');
299
+ expect(message).not.toContain('john');
300
+ expect(message).not.toContain('mysecretpassword');
301
+ });
302
+
303
+ it('should format args as JSON', () => {
304
+ const message = SafeLogger.createApprovalMessage(
305
+ 'Duck',
306
+ 'server',
307
+ 'tool',
308
+ { simple: 'value' }
309
+ );
310
+
311
+ expect(message).toContain('"simple"');
312
+ expect(message).toContain('"value"');
313
+ });
314
+ });
@@ -0,0 +1,239 @@
1
+ import { describe, it, expect, jest, beforeEach } from '@jest/globals';
2
+ import { approveMCPRequestTool } from '../../src/tools/approve-mcp-request.js';
3
+ import { ApprovalService } from '../../src/services/approval.js';
4
+
5
+ // Mock dependencies
6
+ jest.mock('../../src/utils/logger');
7
+ jest.mock('../../src/services/approval.js');
8
+
9
+ describe('approveMCPRequestTool', () => {
10
+ let mockApprovalService: jest.Mocked<ApprovalService>;
11
+
12
+ const mockRequest = {
13
+ id: 'test-request-123',
14
+ duckName: 'TestDuck',
15
+ mcpServer: 'filesystem',
16
+ toolName: 'read_file',
17
+ arguments: { path: '/tmp/test.txt' },
18
+ status: 'pending' as const,
19
+ timestamp: Date.now(),
20
+ expiresAt: Date.now() + 60000,
21
+ };
22
+
23
+ beforeEach(() => {
24
+ mockApprovalService = {
25
+ getApprovalRequest: jest.fn(),
26
+ approveRequest: jest.fn(),
27
+ denyRequest: jest.fn(),
28
+ getPendingApprovals: jest.fn(),
29
+ } as unknown as jest.Mocked<ApprovalService>;
30
+ });
31
+
32
+ describe('validation', () => {
33
+ it('should return error when approval_id is missing', () => {
34
+ const result = approveMCPRequestTool(mockApprovalService, {
35
+ decision: 'approve',
36
+ });
37
+
38
+ expect(result.isError).toBe(true);
39
+ expect(result.content[0].text).toContain('Missing required parameters');
40
+ });
41
+
42
+ it('should return error when decision is missing', () => {
43
+ const result = approveMCPRequestTool(mockApprovalService, {
44
+ approval_id: 'test-123',
45
+ });
46
+
47
+ expect(result.isError).toBe(true);
48
+ expect(result.content[0].text).toContain('Missing required parameters');
49
+ });
50
+
51
+ it('should return error when decision is invalid', () => {
52
+ const result = approveMCPRequestTool(mockApprovalService, {
53
+ approval_id: 'test-123',
54
+ decision: 'maybe',
55
+ });
56
+
57
+ expect(result.isError).toBe(true);
58
+ expect(result.content[0].text).toContain('must be either "approve" or "deny"');
59
+ });
60
+ });
61
+
62
+ describe('request lookup', () => {
63
+ it('should return error when request not found', () => {
64
+ mockApprovalService.getApprovalRequest.mockReturnValue(undefined);
65
+
66
+ const result = approveMCPRequestTool(mockApprovalService, {
67
+ approval_id: 'nonexistent',
68
+ decision: 'approve',
69
+ });
70
+
71
+ expect(result.isError).toBe(true);
72
+ expect(result.content[0].text).toContain('not found');
73
+ });
74
+
75
+ it('should return error when request is not pending', () => {
76
+ mockApprovalService.getApprovalRequest.mockReturnValue({
77
+ ...mockRequest,
78
+ status: 'approved',
79
+ });
80
+
81
+ const result = approveMCPRequestTool(mockApprovalService, {
82
+ approval_id: 'test-123',
83
+ decision: 'approve',
84
+ });
85
+
86
+ expect(result.isError).toBe(true);
87
+ expect(result.content[0].text).toContain('not pending');
88
+ expect(result.content[0].text).toContain('approved');
89
+ });
90
+ });
91
+
92
+ describe('approve decision', () => {
93
+ beforeEach(() => {
94
+ mockApprovalService.getApprovalRequest.mockReturnValue(mockRequest);
95
+ });
96
+
97
+ it('should approve request successfully', () => {
98
+ mockApprovalService.approveRequest.mockReturnValue(true);
99
+
100
+ const result = approveMCPRequestTool(mockApprovalService, {
101
+ approval_id: 'test-request-123',
102
+ decision: 'approve',
103
+ });
104
+
105
+ expect(mockApprovalService.approveRequest).toHaveBeenCalledWith('test-request-123');
106
+ expect(result.isError).toBe(false);
107
+ expect(result.content[0].text).toContain('Approved');
108
+ expect(result.content[0].text).toContain('TestDuck');
109
+ expect(result.content[0].text).toContain('filesystem:read_file');
110
+ });
111
+
112
+ it('should handle approval failure', () => {
113
+ mockApprovalService.approveRequest.mockReturnValue(false);
114
+
115
+ const result = approveMCPRequestTool(mockApprovalService, {
116
+ approval_id: 'test-request-123',
117
+ decision: 'approve',
118
+ });
119
+
120
+ expect(result.isError).toBe(true);
121
+ expect(result.content[0].text).toContain('Failed to approve');
122
+ });
123
+
124
+ it('should include request details in response', () => {
125
+ mockApprovalService.approveRequest.mockReturnValue(true);
126
+
127
+ const result = approveMCPRequestTool(mockApprovalService, {
128
+ approval_id: 'test-request-123',
129
+ decision: 'approve',
130
+ });
131
+
132
+ expect(result.content[0].text).toContain('Request Details');
133
+ expect(result.content[0].text).toContain('TestDuck');
134
+ expect(result.content[0].text).toContain('filesystem');
135
+ expect(result.content[0].text).toContain('read_file');
136
+ expect(result.content[0].text).toContain('/tmp/test.txt');
137
+ });
138
+
139
+ it('should include next steps hint for approval', () => {
140
+ mockApprovalService.approveRequest.mockReturnValue(true);
141
+
142
+ const result = approveMCPRequestTool(mockApprovalService, {
143
+ approval_id: 'test-request-123',
144
+ decision: 'approve',
145
+ });
146
+
147
+ expect(result.content[0].text).toContain('duck can now retry');
148
+ });
149
+ });
150
+
151
+ describe('deny decision', () => {
152
+ beforeEach(() => {
153
+ mockApprovalService.getApprovalRequest.mockReturnValue(mockRequest);
154
+ });
155
+
156
+ it('should deny request successfully', () => {
157
+ mockApprovalService.denyRequest.mockReturnValue(true);
158
+
159
+ const result = approveMCPRequestTool(mockApprovalService, {
160
+ approval_id: 'test-request-123',
161
+ decision: 'deny',
162
+ });
163
+
164
+ expect(mockApprovalService.denyRequest).toHaveBeenCalledWith('test-request-123', undefined);
165
+ expect(result.isError).toBe(false);
166
+ expect(result.content[0].text).toContain('Denied');
167
+ });
168
+
169
+ it('should include reason when provided', () => {
170
+ mockApprovalService.denyRequest.mockReturnValue(true);
171
+
172
+ const result = approveMCPRequestTool(mockApprovalService, {
173
+ approval_id: 'test-request-123',
174
+ decision: 'deny',
175
+ reason: 'Security concern',
176
+ });
177
+
178
+ expect(mockApprovalService.denyRequest).toHaveBeenCalledWith(
179
+ 'test-request-123',
180
+ 'Security concern'
181
+ );
182
+ expect(result.content[0].text).toContain('Reason: Security concern');
183
+ });
184
+
185
+ it('should handle deny failure', () => {
186
+ mockApprovalService.denyRequest.mockReturnValue(false);
187
+
188
+ const result = approveMCPRequestTool(mockApprovalService, {
189
+ approval_id: 'test-request-123',
190
+ decision: 'deny',
191
+ });
192
+
193
+ expect(result.isError).toBe(true);
194
+ expect(result.content[0].text).toContain('Failed to deny');
195
+ });
196
+
197
+ it('should not include next steps hint for denial', () => {
198
+ mockApprovalService.denyRequest.mockReturnValue(true);
199
+
200
+ const result = approveMCPRequestTool(mockApprovalService, {
201
+ approval_id: 'test-request-123',
202
+ decision: 'deny',
203
+ });
204
+
205
+ expect(result.content[0].text).not.toContain('retry');
206
+ });
207
+ });
208
+
209
+ describe('error handling', () => {
210
+ it('should handle exceptions gracefully', () => {
211
+ mockApprovalService.getApprovalRequest.mockImplementation(() => {
212
+ throw new Error('Database connection failed');
213
+ });
214
+
215
+ const result = approveMCPRequestTool(mockApprovalService, {
216
+ approval_id: 'test-123',
217
+ decision: 'approve',
218
+ });
219
+
220
+ expect(result.isError).toBe(true);
221
+ expect(result.content[0].text).toContain('Error processing approval');
222
+ expect(result.content[0].text).toContain('Database connection failed');
223
+ });
224
+
225
+ it('should handle non-Error exceptions', () => {
226
+ mockApprovalService.getApprovalRequest.mockImplementation(() => {
227
+ throw 'String error';
228
+ });
229
+
230
+ const result = approveMCPRequestTool(mockApprovalService, {
231
+ approval_id: 'test-123',
232
+ decision: 'approve',
233
+ });
234
+
235
+ expect(result.isError).toBe(true);
236
+ expect(result.content[0].text).toContain('String error');
237
+ });
238
+ });
239
+ });