mcp-rubber-duck 1.8.0 → 1.9.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/CHANGELOG.md +8 -0
- package/README.md +158 -1
- package/audit-ci.json +2 -1
- package/dist/config/config.d.ts +2 -0
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +144 -1
- package/dist/config/config.js.map +1 -1
- package/dist/config/types.d.ts +1084 -2
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config/types.js +59 -0
- package/dist/config/types.js.map +1 -1
- package/dist/guardrails/context.d.ts +10 -0
- package/dist/guardrails/context.d.ts.map +1 -0
- package/dist/guardrails/context.js +35 -0
- package/dist/guardrails/context.js.map +1 -0
- package/dist/guardrails/errors.d.ts +26 -0
- package/dist/guardrails/errors.d.ts.map +1 -0
- package/dist/guardrails/errors.js +42 -0
- package/dist/guardrails/errors.js.map +1 -0
- package/dist/guardrails/index.d.ts +6 -0
- package/dist/guardrails/index.d.ts.map +1 -0
- package/dist/guardrails/index.js +11 -0
- package/dist/guardrails/index.js.map +1 -0
- package/dist/guardrails/plugins/base-plugin.d.ts +35 -0
- package/dist/guardrails/plugins/base-plugin.d.ts.map +1 -0
- package/dist/guardrails/plugins/base-plugin.js +70 -0
- package/dist/guardrails/plugins/base-plugin.js.map +1 -0
- package/dist/guardrails/plugins/index.d.ts +6 -0
- package/dist/guardrails/plugins/index.d.ts.map +1 -0
- package/dist/guardrails/plugins/index.js +6 -0
- package/dist/guardrails/plugins/index.js.map +1 -0
- package/dist/guardrails/plugins/pattern-blocker.d.ts +27 -0
- package/dist/guardrails/plugins/pattern-blocker.d.ts.map +1 -0
- package/dist/guardrails/plugins/pattern-blocker.js +140 -0
- package/dist/guardrails/plugins/pattern-blocker.js.map +1 -0
- package/dist/guardrails/plugins/pii-redactor/detectors.d.ts +40 -0
- package/dist/guardrails/plugins/pii-redactor/detectors.d.ts.map +1 -0
- package/dist/guardrails/plugins/pii-redactor/detectors.js +134 -0
- package/dist/guardrails/plugins/pii-redactor/detectors.js.map +1 -0
- package/dist/guardrails/plugins/pii-redactor/index.d.ts +28 -0
- package/dist/guardrails/plugins/pii-redactor/index.d.ts.map +1 -0
- package/dist/guardrails/plugins/pii-redactor/index.js +157 -0
- package/dist/guardrails/plugins/pii-redactor/index.js.map +1 -0
- package/dist/guardrails/plugins/pii-redactor/pseudonymizer.d.ts +33 -0
- package/dist/guardrails/plugins/pii-redactor/pseudonymizer.d.ts.map +1 -0
- package/dist/guardrails/plugins/pii-redactor/pseudonymizer.js +70 -0
- package/dist/guardrails/plugins/pii-redactor/pseudonymizer.js.map +1 -0
- package/dist/guardrails/plugins/rate-limiter.d.ts +28 -0
- package/dist/guardrails/plugins/rate-limiter.d.ts.map +1 -0
- package/dist/guardrails/plugins/rate-limiter.js +91 -0
- package/dist/guardrails/plugins/rate-limiter.js.map +1 -0
- package/dist/guardrails/plugins/token-limiter.d.ts +30 -0
- package/dist/guardrails/plugins/token-limiter.d.ts.map +1 -0
- package/dist/guardrails/plugins/token-limiter.js +98 -0
- package/dist/guardrails/plugins/token-limiter.js.map +1 -0
- package/dist/guardrails/service.d.ts +38 -0
- package/dist/guardrails/service.d.ts.map +1 -0
- package/dist/guardrails/service.js +183 -0
- package/dist/guardrails/service.js.map +1 -0
- package/dist/guardrails/types.d.ts +96 -0
- package/dist/guardrails/types.d.ts.map +1 -0
- package/dist/guardrails/types.js +2 -0
- package/dist/guardrails/types.js.map +1 -0
- package/dist/providers/duck-provider-enhanced.d.ts +2 -1
- package/dist/providers/duck-provider-enhanced.d.ts.map +1 -1
- package/dist/providers/duck-provider-enhanced.js +55 -6
- package/dist/providers/duck-provider-enhanced.js.map +1 -1
- package/dist/providers/enhanced-manager.d.ts +2 -1
- package/dist/providers/enhanced-manager.d.ts.map +1 -1
- package/dist/providers/enhanced-manager.js +3 -3
- package/dist/providers/enhanced-manager.js.map +1 -1
- package/dist/providers/manager.d.ts +3 -1
- package/dist/providers/manager.d.ts.map +1 -1
- package/dist/providers/manager.js +4 -2
- package/dist/providers/manager.js.map +1 -1
- package/dist/providers/provider.d.ts +3 -1
- package/dist/providers/provider.d.ts.map +1 -1
- package/dist/providers/provider.js +43 -3
- package/dist/providers/provider.js.map +1 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +28 -6
- package/dist/server.js.map +1 -1
- package/dist/services/function-bridge.d.ts +3 -1
- package/dist/services/function-bridge.d.ts.map +1 -1
- package/dist/services/function-bridge.js +40 -1
- package/dist/services/function-bridge.js.map +1 -1
- package/package.json +1 -1
- package/src/config/config.ts +187 -1
- package/src/config/types.ts +73 -0
- package/src/guardrails/context.ts +37 -0
- package/src/guardrails/errors.ts +46 -0
- package/src/guardrails/index.ts +20 -0
- package/src/guardrails/plugins/base-plugin.ts +103 -0
- package/src/guardrails/plugins/index.ts +5 -0
- package/src/guardrails/plugins/pattern-blocker.ts +190 -0
- package/src/guardrails/plugins/pii-redactor/detectors.ts +200 -0
- package/src/guardrails/plugins/pii-redactor/index.ts +203 -0
- package/src/guardrails/plugins/pii-redactor/pseudonymizer.ts +91 -0
- package/src/guardrails/plugins/rate-limiter.ts +142 -0
- package/src/guardrails/plugins/token-limiter.ts +155 -0
- package/src/guardrails/service.ts +209 -0
- package/src/guardrails/types.ts +120 -0
- package/src/providers/duck-provider-enhanced.ts +76 -7
- package/src/providers/enhanced-manager.ts +5 -3
- package/src/providers/manager.ts +6 -3
- package/src/providers/provider.ts +57 -6
- package/src/server.ts +32 -6
- package/src/services/function-bridge.ts +53 -2
- package/tests/guardrails/config.test.ts +267 -0
- package/tests/guardrails/errors.test.ts +109 -0
- package/tests/guardrails/plugins/pattern-blocker.test.ts +309 -0
- package/tests/guardrails/plugins/pii-redactor.test.ts +1004 -0
- package/tests/guardrails/plugins/rate-limiter.test.ts +310 -0
- package/tests/guardrails/plugins/token-limiter.test.ts +216 -0
- package/tests/guardrails/service.test.ts +911 -0
- package/tests/mcp-bridge.test.ts +248 -0
- package/tests/providers.test.ts +739 -0
package/tests/mcp-bridge.test.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { jest } from '@jest/globals';
|
|
|
2
2
|
import { ApprovalService } from '../src/services/approval';
|
|
3
3
|
import { FunctionBridge } from '../src/services/function-bridge';
|
|
4
4
|
import { MCPClientManager } from '../src/services/mcp-client-manager';
|
|
5
|
+
import { GuardrailsService } from '../src/guardrails/service';
|
|
6
|
+
import { GuardrailBlockError } from '../src/guardrails/errors';
|
|
5
7
|
|
|
6
8
|
describe('MCP Bridge', () => {
|
|
7
9
|
let approvalService: ApprovalService;
|
|
@@ -288,4 +290,250 @@ describe('MCP Bridge', () => {
|
|
|
288
290
|
expect(functions[0].description).toBe('[filesystem] Read a file');
|
|
289
291
|
});
|
|
290
292
|
});
|
|
293
|
+
|
|
294
|
+
describe('FunctionBridge with Guardrails', () => {
|
|
295
|
+
let guardedFunctionBridge: FunctionBridge;
|
|
296
|
+
let mockGuardrailsService: {
|
|
297
|
+
isEnabled: jest.Mock;
|
|
298
|
+
createContext: jest.Mock;
|
|
299
|
+
execute: jest.Mock;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
beforeEach(() => {
|
|
303
|
+
// Create mock guardrails service
|
|
304
|
+
mockGuardrailsService = {
|
|
305
|
+
isEnabled: jest.fn().mockReturnValue(true),
|
|
306
|
+
createContext: jest.fn().mockImplementation((params) => ({
|
|
307
|
+
requestId: 'test-request-id',
|
|
308
|
+
toolName: params.toolName,
|
|
309
|
+
toolArgs: params.toolArgs,
|
|
310
|
+
violations: [],
|
|
311
|
+
modifications: [],
|
|
312
|
+
metadata: new Map(),
|
|
313
|
+
})),
|
|
314
|
+
execute: jest.fn().mockResolvedValue({ action: 'allow', context: {} }),
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
guardedFunctionBridge = new FunctionBridge(
|
|
318
|
+
mcpManager,
|
|
319
|
+
approvalService,
|
|
320
|
+
[],
|
|
321
|
+
'never', // Never require approval for tests
|
|
322
|
+
{},
|
|
323
|
+
mockGuardrailsService as unknown as GuardrailsService
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('should execute pre_tool_input guardrails before tool execution', async () => {
|
|
328
|
+
// Mock the MCP manager to simulate a connected server
|
|
329
|
+
jest.spyOn(mcpManager, 'callTool').mockResolvedValue({ result: 'test result' });
|
|
330
|
+
|
|
331
|
+
await guardedFunctionBridge.handleFunctionCall(
|
|
332
|
+
'TestDuck',
|
|
333
|
+
'mcp__test_server__test_tool',
|
|
334
|
+
{ _mcp_server: 'test_server', _mcp_tool: 'test_tool', arg1: 'value1' }
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
expect(mockGuardrailsService.isEnabled).toHaveBeenCalled();
|
|
338
|
+
expect(mockGuardrailsService.createContext).toHaveBeenCalledWith(
|
|
339
|
+
expect.objectContaining({
|
|
340
|
+
toolName: 'test_server:test_tool',
|
|
341
|
+
})
|
|
342
|
+
);
|
|
343
|
+
expect(mockGuardrailsService.execute).toHaveBeenCalledWith('pre_tool_input', expect.any(Object));
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('should execute post_tool_output guardrails after tool execution', async () => {
|
|
347
|
+
jest.spyOn(mcpManager, 'callTool').mockResolvedValue({ result: 'test result' });
|
|
348
|
+
|
|
349
|
+
await guardedFunctionBridge.handleFunctionCall(
|
|
350
|
+
'TestDuck',
|
|
351
|
+
'mcp__test_server__test_tool',
|
|
352
|
+
{ _mcp_server: 'test_server', _mcp_tool: 'test_tool' }
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
// Should be called twice: pre_tool_input and post_tool_output
|
|
356
|
+
expect(mockGuardrailsService.execute).toHaveBeenCalledTimes(2);
|
|
357
|
+
expect(mockGuardrailsService.execute).toHaveBeenNthCalledWith(1, 'pre_tool_input', expect.any(Object));
|
|
358
|
+
expect(mockGuardrailsService.execute).toHaveBeenNthCalledWith(2, 'post_tool_output', expect.any(Object));
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('should block tool input when pre_tool_input guardrails return block', async () => {
|
|
362
|
+
mockGuardrailsService.execute.mockResolvedValueOnce({
|
|
363
|
+
action: 'block',
|
|
364
|
+
blockedBy: 'pattern_blocker',
|
|
365
|
+
blockReason: 'Sensitive data detected in tool arguments',
|
|
366
|
+
context: {},
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const callToolSpy = jest.spyOn(mcpManager, 'callTool');
|
|
370
|
+
|
|
371
|
+
await expect(
|
|
372
|
+
guardedFunctionBridge.handleFunctionCall(
|
|
373
|
+
'TestDuck',
|
|
374
|
+
'mcp__test_server__test_tool',
|
|
375
|
+
{ _mcp_server: 'test_server', _mcp_tool: 'test_tool', secret: 'password123' }
|
|
376
|
+
)
|
|
377
|
+
).rejects.toThrow("Request blocked by guardrail 'pattern_blocker': Sensitive data detected in tool arguments");
|
|
378
|
+
|
|
379
|
+
// Should NOT call the MCP tool when blocked
|
|
380
|
+
expect(callToolSpy).not.toHaveBeenCalled();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('should block tool output when post_tool_output guardrails return block', async () => {
|
|
384
|
+
jest.spyOn(mcpManager, 'callTool').mockResolvedValue({ sensitiveData: 'should_be_blocked' });
|
|
385
|
+
|
|
386
|
+
// Pre-tool allows, post-tool blocks
|
|
387
|
+
mockGuardrailsService.execute
|
|
388
|
+
.mockResolvedValueOnce({ action: 'allow', context: {} })
|
|
389
|
+
.mockResolvedValueOnce({
|
|
390
|
+
action: 'block',
|
|
391
|
+
blockedBy: 'pii_redactor',
|
|
392
|
+
blockReason: 'Sensitive data in tool output',
|
|
393
|
+
context: {},
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
await expect(
|
|
397
|
+
guardedFunctionBridge.handleFunctionCall(
|
|
398
|
+
'TestDuck',
|
|
399
|
+
'mcp__test_server__test_tool',
|
|
400
|
+
{ _mcp_server: 'test_server', _mcp_tool: 'test_tool' }
|
|
401
|
+
)
|
|
402
|
+
).rejects.toThrow("Request blocked by guardrail 'pii_redactor': Sensitive data in tool output");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('should modify tool args when pre_tool_input guardrails return modify', async () => {
|
|
406
|
+
const modifiedArgs = { arg1: '[REDACTED]', _mcp_server: 'test_server', _mcp_tool: 'test_tool' };
|
|
407
|
+
|
|
408
|
+
mockGuardrailsService.execute.mockImplementation((phase) => {
|
|
409
|
+
if (phase === 'pre_tool_input') {
|
|
410
|
+
return Promise.resolve({
|
|
411
|
+
action: 'modify',
|
|
412
|
+
context: { toolArgs: modifiedArgs },
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
return Promise.resolve({ action: 'allow', context: {} });
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
mockGuardrailsService.createContext.mockReturnValue({
|
|
419
|
+
requestId: 'test-id',
|
|
420
|
+
toolName: 'test_server:test_tool',
|
|
421
|
+
toolArgs: modifiedArgs,
|
|
422
|
+
violations: [],
|
|
423
|
+
modifications: [],
|
|
424
|
+
metadata: new Map(),
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const callToolSpy = jest.spyOn(mcpManager, 'callTool').mockResolvedValue({ result: 'ok' });
|
|
428
|
+
|
|
429
|
+
await guardedFunctionBridge.handleFunctionCall(
|
|
430
|
+
'TestDuck',
|
|
431
|
+
'mcp__test_server__test_tool',
|
|
432
|
+
{ _mcp_server: 'test_server', _mcp_tool: 'test_tool', arg1: 'sensitive_value' }
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
// The MCP tool should receive the modified args
|
|
436
|
+
expect(callToolSpy).toHaveBeenCalledWith(
|
|
437
|
+
'test_server',
|
|
438
|
+
'test_tool',
|
|
439
|
+
expect.objectContaining({ arg1: '[REDACTED]' })
|
|
440
|
+
);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('should modify tool result when post_tool_output guardrails return modify', async () => {
|
|
444
|
+
const sharedContext: {
|
|
445
|
+
requestId: string;
|
|
446
|
+
toolName: string;
|
|
447
|
+
toolArgs: Record<string, unknown>;
|
|
448
|
+
toolResult: unknown;
|
|
449
|
+
violations: unknown[];
|
|
450
|
+
modifications: unknown[];
|
|
451
|
+
metadata: Map<string, unknown>;
|
|
452
|
+
} = {
|
|
453
|
+
requestId: 'test-id',
|
|
454
|
+
toolName: 'test_server:test_tool',
|
|
455
|
+
toolArgs: {},
|
|
456
|
+
toolResult: undefined,
|
|
457
|
+
violations: [],
|
|
458
|
+
modifications: [],
|
|
459
|
+
metadata: new Map(),
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
mockGuardrailsService.createContext.mockReturnValue(sharedContext);
|
|
463
|
+
|
|
464
|
+
mockGuardrailsService.execute.mockImplementation((phase) => {
|
|
465
|
+
if (phase === 'post_tool_output') {
|
|
466
|
+
sharedContext.toolResult = { redacted: '[SENSITIVE DATA REMOVED]' };
|
|
467
|
+
return Promise.resolve({
|
|
468
|
+
action: 'modify',
|
|
469
|
+
context: sharedContext,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
return Promise.resolve({ action: 'allow', context: sharedContext });
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
jest.spyOn(mcpManager, 'callTool').mockResolvedValue({
|
|
476
|
+
sensitiveField: 'actual_password_here'
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
const result = await guardedFunctionBridge.handleFunctionCall(
|
|
480
|
+
'TestDuck',
|
|
481
|
+
'mcp__test_server__test_tool',
|
|
482
|
+
{ _mcp_server: 'test_server', _mcp_tool: 'test_tool' }
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
expect(result.success).toBe(true);
|
|
486
|
+
expect(result.data).toEqual({ redacted: '[SENSITIVE DATA REMOVED]' });
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('should skip guardrails when service is disabled', async () => {
|
|
490
|
+
mockGuardrailsService.isEnabled.mockReturnValue(false);
|
|
491
|
+
|
|
492
|
+
jest.spyOn(mcpManager, 'callTool').mockResolvedValue({ result: 'test' });
|
|
493
|
+
|
|
494
|
+
const result = await guardedFunctionBridge.handleFunctionCall(
|
|
495
|
+
'TestDuck',
|
|
496
|
+
'mcp__test_server__test_tool',
|
|
497
|
+
{ _mcp_server: 'test_server', _mcp_tool: 'test_tool' }
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
expect(result.success).toBe(true);
|
|
501
|
+
expect(mockGuardrailsService.createContext).not.toHaveBeenCalled();
|
|
502
|
+
expect(mockGuardrailsService.execute).not.toHaveBeenCalled();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('should work without guardrails service (undefined)', async () => {
|
|
506
|
+
const bridgeWithoutGuardrails = new FunctionBridge(
|
|
507
|
+
mcpManager,
|
|
508
|
+
approvalService,
|
|
509
|
+
[],
|
|
510
|
+
'never'
|
|
511
|
+
// No guardrails service
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
jest.spyOn(mcpManager, 'callTool').mockResolvedValue({ result: 'test' });
|
|
515
|
+
|
|
516
|
+
const result = await bridgeWithoutGuardrails.handleFunctionCall(
|
|
517
|
+
'TestDuck',
|
|
518
|
+
'mcp__test_server__test_tool',
|
|
519
|
+
{ _mcp_server: 'test_server', _mcp_tool: 'test_tool' }
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
expect(result.success).toBe(true);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('should re-throw GuardrailBlockError without wrapping', async () => {
|
|
526
|
+
mockGuardrailsService.execute.mockRejectedValueOnce(
|
|
527
|
+
new GuardrailBlockError('custom_plugin', 'Custom block reason')
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
await expect(
|
|
531
|
+
guardedFunctionBridge.handleFunctionCall(
|
|
532
|
+
'TestDuck',
|
|
533
|
+
'mcp__test_server__test_tool',
|
|
534
|
+
{ _mcp_server: 'test_server', _mcp_tool: 'test_tool' }
|
|
535
|
+
)
|
|
536
|
+
).rejects.toThrow("Request blocked by guardrail 'custom_plugin': Custom block reason");
|
|
537
|
+
});
|
|
538
|
+
});
|
|
291
539
|
});
|