import { jest } from '@jest/globals'; import { ApprovalService } from '../src/services/approval'; import { FunctionBridge } from '../src/services/function-bridge'; import { MCPClientManager } from '../src/services/mcp-client-manager'; import { GuardrailsService } from '../src/guardrails/service'; import { GuardrailBlockError } from '../src/guardrails/errors'; describe('MCP Bridge', () => { let approvalService: ApprovalService; let mcpManager: MCPClientManager; let functionBridge: FunctionBridge; beforeEach(() => { approvalService = new ApprovalService(300); // 5 minutes mcpManager = new MCPClientManager([]); // Empty config for testing functionBridge = new FunctionBridge(mcpManager, approvalService, []); }); afterEach(() => { approvalService.shutdown(); }); describe('ApprovalService', () => { it('should create approval requests', () => { const request = approvalService.createApprovalRequest( 'TestDuck', 'filesystem', 'read_file', { path: '/test.txt' } ); expect(request).toBeDefined(); expect(request.duckName).toBe('TestDuck'); expect(request.mcpServer).toBe('filesystem'); expect(request.toolName).toBe('read_file'); expect(request.status).toBe('pending'); }); it('should approve pending requests', () => { const request = approvalService.createApprovalRequest( 'TestDuck', 'filesystem', 'read_file', { path: '/test.txt' } ); const approved = approvalService.approveRequest(request.id); expect(approved).toBe(true); const status = approvalService.getApprovalStatus(request.id); expect(status).toBe('approved'); }); it('should deny requests', () => { const request = approvalService.createApprovalRequest( 'TestDuck', 'filesystem', 'read_file', { path: '/test.txt' } ); const denied = approvalService.denyRequest(request.id, 'Security concern'); expect(denied).toBe(true); const status = approvalService.getApprovalStatus(request.id); expect(status).toBe('denied'); }); it('should handle non-existent requests', () => { const status = approvalService.getApprovalStatus('non-existent'); expect(status).toBeUndefined(); const approved = approvalService.approveRequest('non-existent'); expect(approved).toBe(false); }); it('should get pending approvals', () => { approvalService.createApprovalRequest('Duck1', 'server1', 'tool1', {}); approvalService.createApprovalRequest('Duck2', 'server2', 'tool2', {}); const pending = approvalService.getPendingApprovals(); expect(pending).toHaveLength(2); expect(pending.every(req => req.status === 'pending')).toBe(true); }); }); describe('FunctionBridge', () => { it('should generate function definitions for empty MCP tools', async () => { const functions = await functionBridge.getFunctionDefinitions(); expect(Array.isArray(functions)).toBe(true); // Should be empty since we have no MCP servers configured expect(functions).toHaveLength(0); }); it('should handle function calls requiring approval', async () => { const result = await functionBridge.handleFunctionCall( 'TestDuck', 'mcp__filesystem__read_file', { path: '/test.txt', _mcp_server: 'filesystem', _mcp_tool: 'read_file' } ); expect(result.success).toBe(false); expect(result.needsApproval).toBe(true); expect(result.approvalId).toBeDefined(); }); it('should handle invalid function names', async () => { const result = await functionBridge.handleFunctionCall( 'TestDuck', 'invalid_function', {} ); expect(result.success).toBe(false); expect(result.error).toContain('Invalid function name'); }); it('should validate tool arguments', async () => { // Mock a tool with schema in the functionBridge const mockTool = { serverName: 'test_server', name: 'test_tool', description: 'Test tool', inputSchema: { type: 'object', properties: { required_param: { type: 'string' } }, required: ['required_param'] } }; // Add the tool schema manually for testing (functionBridge as any).toolSchemas.set('test_server:test_tool', mockTool.inputSchema); const result = await functionBridge.handleFunctionCall( 'TestDuck', 'mcp__test_server__test_tool', { _mcp_server: 'test_server', _mcp_tool: 'test_tool', // Missing required_param } ); expect(result.success).toBe(false); expect(result.error).toContain('Invalid arguments'); }); it('should handle underscored tool names correctly', async () => { // Test that tool names with underscores are extracted correctly const serverName = (functionBridge as any).extractServerFromFunctionName('mcp__file_system__read_file'); const toolName = (functionBridge as any).extractToolFromFunctionName('mcp__file_system__read_file'); expect(serverName).toBe('file_system'); expect(toolName).toBe('read_file'); }); it('should handle complex server and tool names', async () => { // Test more complex names const serverName = (functionBridge as any).extractServerFromFunctionName('mcp__complex_server_name__complex_tool_name'); const toolName = (functionBridge as any).extractToolFromFunctionName('mcp__complex_server_name__complex_tool_name'); expect(serverName).toBe('complex_server_name'); expect(toolName).toBe('complex_tool_name'); }); }); describe('MCPClientManager', () => { it('should initialize with empty config', async () => { expect(mcpManager.getConnectedServers()).toEqual([]); expect(mcpManager.getConnectionStatus('nonexistent')).toBe('unknown'); }); it('should handle health check with no servers', async () => { const health = await mcpManager.healthCheck(); expect(health).toEqual({}); }); it('should get status of all servers', () => { const status = mcpManager.getStatus(); expect(typeof status).toBe('object'); }); }); describe('Integration Tests', () => { it('should handle complete approval workflow', async () => { // Create approval request const request = approvalService.createApprovalRequest( 'TestDuck', 'filesystem', 'read_file', { path: '/test.txt' } ); expect(request.status).toBe('pending'); // Try function call without approval const result1 = await functionBridge.handleFunctionCall( 'TestDuck', 'mcp__filesystem__read_file', { path: '/test.txt', _mcp_server: 'filesystem', _mcp_tool: 'read_file' } ); expect(result1.success).toBe(false); expect(result1.needsApproval).toBe(true); // Approve the request const approved = approvalService.approveRequest(request.id); expect(approved).toBe(true); // Try function call with approval (would still fail due to no MCP server, but approval logic works) const result2 = await functionBridge.handleFunctionCall( 'TestDuck', 'mcp__filesystem__read_file', { path: '/test.txt', _mcp_server: 'filesystem', _mcp_tool: 'read_file', _approval_id: request.id } ); // Should pass approval but fail on MCP execution expect(result2.error).toContain('MCP server filesystem not connected'); }); it('should handle expired approvals', (done) => { // Create approval service with very short timeout const shortApprovalService = new ApprovalService(1); // 1 second const request = shortApprovalService.createApprovalRequest( 'TestDuck', 'filesystem', 'read_file', { path: '/test.txt' } ); // Wait for expiration setTimeout(() => { const status = shortApprovalService.getApprovalStatus(request.id); expect(status).toBe('expired'); // Try to approve expired request const approved = shortApprovalService.approveRequest(request.id); expect(approved).toBe(false); shortApprovalService.shutdown(); done(); }, 1100); }); it('should handle function definition generation', async () => { // Mock MCP tools const mockTools = [ { serverName: 'filesystem', name: 'read_file', description: 'Read a file', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } }, { serverName: 'web', name: 'fetch_url', description: 'Fetch URL content', inputSchema: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } } ]; // Mock the listAllTools method jest.spyOn(mcpManager, 'listAllTools').mockResolvedValue(mockTools); const functions = await functionBridge.getFunctionDefinitions(); expect(functions).toHaveLength(2); expect(functions[0].name).toBe('mcp__filesystem__read_file'); expect(functions[1].name).toBe('mcp__web__fetch_url'); expect(functions[0].description).toBe('[filesystem] Read a file'); }); }); describe('FunctionBridge with Guardrails', () => { let guardedFunctionBridge: FunctionBridge; let mockGuardrailsService: { isEnabled: jest.Mock; createContext: jest.Mock; execute: jest.Mock; }; beforeEach(() => { // Create mock guardrails service mockGuardrailsService = { isEnabled: jest.fn().mockReturnValue(true), createContext: jest.fn().mockImplementation((params) => ({ requestId: 'test-request-id', toolName: params.toolName, toolArgs: params.toolArgs, violations: [], modifications: [], metadata: new Map(), })), execute: jest.fn().mockResolvedValue({ action: 'allow', context: {} }), }; guardedFunctionBridge = new FunctionBridge( mcpManager, approvalService, [], 'never', // Never require approval for tests {}, mockGuardrailsService as unknown as GuardrailsService ); }); it('should execute pre_tool_input guardrails before tool execution', async () => { // Mock the MCP manager to simulate a connected server jest.spyOn(mcpManager, 'callTool').mockResolvedValue({ result: 'test result' }); await guardedFunctionBridge.handleFunctionCall( 'TestDuck', 'mcp__test_server__test_tool', { _mcp_server: 'test_server', _mcp_tool: 'test_tool', arg1: 'value1' } ); expect(mockGuardrailsService.isEnabled).toHaveBeenCalled(); expect(mockGuardrailsService.createContext).toHaveBeenCalledWith( expect.objectContaining({ toolName: 'test_server:test_tool', }) ); expect(mockGuardrailsService.execute).toHaveBeenCalledWith('pre_tool_input', expect.any(Object)); }); it('should execute post_tool_output guardrails after tool execution', async () => { jest.spyOn(mcpManager, 'callTool').mockResolvedValue({ result: 'test result' }); await guardedFunctionBridge.handleFunctionCall( 'TestDuck', 'mcp__test_server__test_tool', { _mcp_server: 'test_server', _mcp_tool: 'test_tool' } ); // Should be called twice: pre_tool_input and post_tool_output expect(mockGuardrailsService.execute).toHaveBeenCalledTimes(2); expect(mockGuardrailsService.execute).toHaveBeenNthCalledWith(1, 'pre_tool_input', expect.any(Object)); expect(mockGuardrailsService.execute).toHaveBeenNthCalledWith(2, 'post_tool_output', expect.any(Object)); }); it('should block tool input when pre_tool_input guardrails return block', async () => { mockGuardrailsService.execute.mockResolvedValueOnce({ action: 'block', blockedBy: 'pattern_blocker', blockReason: 'Sensitive data detected in tool arguments', context: {}, }); const callToolSpy = jest.spyOn(mcpManager, 'callTool'); await expect( guardedFunctionBridge.handleFunctionCall( 'TestDuck', 'mcp__test_server__test_tool', { _mcp_server: 'test_server', _mcp_tool: 'test_tool', secret: 'password123' } ) ).rejects.toThrow("Request blocked by guardrail 'pattern_blocker': Sensitive data detected in tool arguments"); // Should NOT call the MCP tool when blocked expect(callToolSpy).not.toHaveBeenCalled(); }); it('should block tool output when post_tool_output guardrails return block', async () => { jest.spyOn(mcpManager, 'callTool').mockResolvedValue({ sensitiveData: 'should_be_blocked' }); // Pre-tool allows, post-tool blocks mockGuardrailsService.execute .mockResolvedValueOnce({ action: 'allow', context: {} }) .mockResolvedValueOnce({ action: 'block', blockedBy: 'pii_redactor', blockReason: 'Sensitive data in tool output', context: {}, }); await expect( guardedFunctionBridge.handleFunctionCall( 'TestDuck', 'mcp__test_server__test_tool', { _mcp_server: 'test_server', _mcp_tool: 'test_tool' } ) ).rejects.toThrow("Request blocked by guardrail 'pii_redactor': Sensitive data in tool output"); }); it('should modify tool args when pre_tool_input guardrails return modify', async () => { const modifiedArgs = { arg1: '[REDACTED]', _mcp_server: 'test_server', _mcp_tool: 'test_tool' }; mockGuardrailsService.execute.mockImplementation((phase) => { if (phase === 'pre_tool_input') { return Promise.resolve({ action: 'modify', context: { toolArgs: modifiedArgs }, }); } return Promise.resolve({ action: 'allow', context: {} }); }); mockGuardrailsService.createContext.mockReturnValue({ requestId: 'test-id', toolName: 'test_server:test_tool', toolArgs: modifiedArgs, violations: [], modifications: [], metadata: new Map(), }); const callToolSpy = jest.spyOn(mcpManager, 'callTool').mockResolvedValue({ result: 'ok' }); await guardedFunctionBridge.handleFunctionCall( 'TestDuck', 'mcp__test_server__test_tool', { _mcp_server: 'test_server', _mcp_tool: 'test_tool', arg1: 'sensitive_value' } ); // The MCP tool should receive the modified args expect(callToolSpy).toHaveBeenCalledWith( 'test_server', 'test_tool', expect.objectContaining({ arg1: '[REDACTED]' }) ); }); it('should modify tool result when post_tool_output guardrails return modify', async () => { const sharedContext: { requestId: string; toolName: string; toolArgs: Record; toolResult: unknown; violations: unknown[]; modifications: unknown[]; metadata: Map; } = { requestId: 'test-id', toolName: 'test_server:test_tool', toolArgs: {}, toolResult: undefined, violations: [], modifications: [], metadata: new Map(), }; mockGuardrailsService.createContext.mockReturnValue(sharedContext); mockGuardrailsService.execute.mockImplementation((phase) => { if (phase === 'post_tool_output') { sharedContext.toolResult = { redacted: '[SENSITIVE DATA REMOVED]' }; return Promise.resolve({ action: 'modify', context: sharedContext, }); } return Promise.resolve({ action: 'allow', context: sharedContext }); }); jest.spyOn(mcpManager, 'callTool').mockResolvedValue({ sensitiveField: 'actual_password_here' }); const result = await guardedFunctionBridge.handleFunctionCall( 'TestDuck', 'mcp__test_server__test_tool', { _mcp_server: 'test_server', _mcp_tool: 'test_tool' } ); expect(result.success).toBe(true); expect(result.data).toEqual({ redacted: '[SENSITIVE DATA REMOVED]' }); }); it('should skip guardrails when service is disabled', async () => { mockGuardrailsService.isEnabled.mockReturnValue(false); jest.spyOn(mcpManager, 'callTool').mockResolvedValue({ result: 'test' }); const result = await guardedFunctionBridge.handleFunctionCall( 'TestDuck', 'mcp__test_server__test_tool', { _mcp_server: 'test_server', _mcp_tool: 'test_tool' } ); expect(result.success).toBe(true); expect(mockGuardrailsService.createContext).not.toHaveBeenCalled(); expect(mockGuardrailsService.execute).not.toHaveBeenCalled(); }); it('should work without guardrails service (undefined)', async () => { const bridgeWithoutGuardrails = new FunctionBridge( mcpManager, approvalService, [], 'never' // No guardrails service ); jest.spyOn(mcpManager, 'callTool').mockResolvedValue({ result: 'test' }); const result = await bridgeWithoutGuardrails.handleFunctionCall( 'TestDuck', 'mcp__test_server__test_tool', { _mcp_server: 'test_server', _mcp_tool: 'test_tool' } ); expect(result.success).toBe(true); }); it('should re-throw GuardrailBlockError without wrapping', async () => { mockGuardrailsService.execute.mockRejectedValueOnce( new GuardrailBlockError('custom_plugin', 'Custom block reason') ); await expect( guardedFunctionBridge.handleFunctionCall( 'TestDuck', 'mcp__test_server__test_tool', { _mcp_server: 'test_server', _mcp_tool: 'test_tool' } ) ).rejects.toThrow("Request blocked by guardrail 'custom_plugin': Custom block reason"); }); }); });