Files
n8n-mcp/tests/unit/mcp/disabled-tools-additional.test.ts
czlonkowski 53252adc68 feat: Add DISABLED_TOOLS environment variable for tool filtering (Issue #410)
Added DISABLED_TOOLS environment variable to filter specific tools from registration at startup, enabling deployment-specific tool configuration for multi-tenant deployments, security hardening, and feature flags.

## Implementation

- Added getDisabledTools() method to parse comma-separated tool names from env var
- Modified ListToolsRequestSchema handler to filter both documentation and management tools
- Modified CallToolRequestSchema handler to reject disabled tool calls with clear error messages
- Added defense-in-depth guard in executeTool() method

## Features

- Environment variable format: DISABLED_TOOLS=tool1,tool2,tool3
- O(1) lookup performance using Set data structure
- Clear error messages with TOOL_DISABLED code
- Backward compatible (no DISABLED_TOOLS = all tools enabled)
- Comprehensive logging for observability

## Use Cases

- Multi-tenant: Hide tools that check global env vars
- Security: Disable management tools in production
- Feature flags: Gradually roll out new tools
- Deployment-specific: Different tool sets for cloud vs self-hosted

## Testing

- 45 comprehensive tests (all passing)
- 95% feature code coverage
- Unit tests + additional test scenarios
- Performance tested with 1000 tools (<100ms)

## Files Modified

- src/mcp/server.ts - Core implementation (~40 lines)
- .env.example, .env.docker - Configuration documentation
- tests/unit/mcp/disabled-tools*.test.ts - Comprehensive tests
- package.json, package.runtime.json - Version bump to 2.22.14
- CHANGELOG.md - Full documentation

Resolves #410

Conceived by Romuald Członkowski - www.aiadvisors.pl/en
2025-11-09 16:26:47 +01:00

364 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { N8NDocumentationMCPServer } from '../../../src/mcp/server';
// Mock the database and dependencies
vi.mock('../../../src/database/database-adapter');
vi.mock('../../../src/database/node-repository');
vi.mock('../../../src/templates/template-service');
vi.mock('../../../src/utils/logger');
class TestableN8NMCPServer extends N8NDocumentationMCPServer {
// Expose the private getDisabledTools method for testing
public testGetDisabledTools(): Set<string> {
return (this as any).getDisabledTools();
}
// Expose the private executeTool method for testing
public async testExecuteTool(name: string, args: any): Promise<any> {
return (this as any).executeTool(name, args);
}
}
describe('Disabled Tools Additional Coverage (Issue #410)', () => {
let server: TestableN8NMCPServer;
beforeEach(() => {
// Set environment variable to use in-memory database
process.env.NODE_DB_PATH = ':memory:';
});
afterEach(() => {
delete process.env.NODE_DB_PATH;
delete process.env.DISABLED_TOOLS;
delete process.env.ENABLE_MULTI_TENANT;
delete process.env.N8N_API_URL;
delete process.env.N8N_API_KEY;
});
describe('Error Response Structure Validation', () => {
it('should throw error with specific message format', async () => {
process.env.DISABLED_TOOLS = 'test_tool';
server = new TestableN8NMCPServer();
let thrownError: Error | null = null;
try {
await server.testExecuteTool('test_tool', {});
} catch (error) {
thrownError = error as Error;
}
// Verify error was thrown
expect(thrownError).not.toBeNull();
expect(thrownError?.message).toBe(
"Tool 'test_tool' is disabled via DISABLED_TOOLS environment variable"
);
});
it('should include tool name in error message', async () => {
const toolName = 'my_special_tool';
process.env.DISABLED_TOOLS = toolName;
server = new TestableN8NMCPServer();
let errorMessage = '';
try {
await server.testExecuteTool(toolName, {});
} catch (error: any) {
errorMessage = error.message;
}
expect(errorMessage).toContain(toolName);
expect(errorMessage).toContain('disabled via DISABLED_TOOLS');
});
it('should throw consistent error format for all disabled tools', async () => {
const tools = ['tool1', 'tool2', 'tool3'];
process.env.DISABLED_TOOLS = tools.join(',');
server = new TestableN8NMCPServer();
for (const tool of tools) {
let errorMessage = '';
try {
await server.testExecuteTool(tool, {});
} catch (error: any) {
errorMessage = error.message;
}
// Verify consistent error format
expect(errorMessage).toMatch(/^Tool '.*' is disabled via DISABLED_TOOLS environment variable$/);
expect(errorMessage).toContain(tool);
}
});
});
describe('Multi-Tenant Mode Interaction', () => {
it('should respect DISABLED_TOOLS in multi-tenant mode', () => {
process.env.ENABLE_MULTI_TENANT = 'true';
process.env.DISABLED_TOOLS = 'n8n_delete_workflow,n8n_update_full_workflow';
delete process.env.N8N_API_URL;
delete process.env.N8N_API_KEY;
server = new TestableN8NMCPServer();
const disabledTools = server.testGetDisabledTools();
// Even in multi-tenant mode, disabled tools should be filtered
expect(disabledTools.has('n8n_delete_workflow')).toBe(true);
expect(disabledTools.has('n8n_update_full_workflow')).toBe(true);
expect(disabledTools.size).toBe(2);
});
it('should parse DISABLED_TOOLS regardless of N8N_API_URL setting', () => {
process.env.DISABLED_TOOLS = 'tool1,tool2';
process.env.N8N_API_URL = 'http://localhost:5678';
process.env.N8N_API_KEY = 'test-key';
server = new TestableN8NMCPServer();
const disabledTools = server.testGetDisabledTools();
expect(disabledTools.size).toBe(2);
expect(disabledTools.has('tool1')).toBe(true);
expect(disabledTools.has('tool2')).toBe(true);
});
it('should work when only ENABLE_MULTI_TENANT is set', () => {
process.env.ENABLE_MULTI_TENANT = 'true';
process.env.DISABLED_TOOLS = 'restricted_tool';
server = new TestableN8NMCPServer();
const disabledTools = server.testGetDisabledTools();
expect(disabledTools.has('restricted_tool')).toBe(true);
});
});
describe('Edge Cases - Special Characters and Unicode', () => {
it('should handle unicode tool names correctly', () => {
process.env.DISABLED_TOOLS = 'tool_测试,tool_münchen,tool_العربية';
server = new TestableN8NMCPServer();
const disabledTools = server.testGetDisabledTools();
expect(disabledTools.size).toBe(3);
expect(disabledTools.has('tool_测试')).toBe(true);
expect(disabledTools.has('tool_münchen')).toBe(true);
expect(disabledTools.has('tool_العربية')).toBe(true);
});
it('should handle emoji in tool names', () => {
process.env.DISABLED_TOOLS = 'tool_🎯,tool_✅,tool_❌';
server = new TestableN8NMCPServer();
const disabledTools = server.testGetDisabledTools();
expect(disabledTools.size).toBe(3);
expect(disabledTools.has('tool_🎯')).toBe(true);
expect(disabledTools.has('tool_✅')).toBe(true);
expect(disabledTools.has('tool_❌')).toBe(true);
});
it('should treat regex special characters as literals', () => {
process.env.DISABLED_TOOLS = 'tool.*,tool[0-9],tool(test)';
server = new TestableN8NMCPServer();
const disabledTools = server.testGetDisabledTools();
// These should be treated as literal strings, not regex patterns
expect(disabledTools.has('tool.*')).toBe(true);
expect(disabledTools.has('tool[0-9]')).toBe(true);
expect(disabledTools.has('tool(test)')).toBe(true);
expect(disabledTools.size).toBe(3);
});
it('should handle tool names with dots and colons', () => {
process.env.DISABLED_TOOLS = 'org.example.tool,namespace:tool:v1';
server = new TestableN8NMCPServer();
const disabledTools = server.testGetDisabledTools();
expect(disabledTools.has('org.example.tool')).toBe(true);
expect(disabledTools.has('namespace:tool:v1')).toBe(true);
});
it('should handle tool names with @ symbols', () => {
process.env.DISABLED_TOOLS = '@scope/tool,user@tool';
server = new TestableN8NMCPServer();
const disabledTools = server.testGetDisabledTools();
expect(disabledTools.has('@scope/tool')).toBe(true);
expect(disabledTools.has('user@tool')).toBe(true);
});
});
describe('Performance and Scale', () => {
it('should handle 100 disabled tools efficiently', () => {
const manyTools = Array.from({ length: 100 }, (_, i) => `tool_${i}`);
process.env.DISABLED_TOOLS = manyTools.join(',');
const start = Date.now();
server = new TestableN8NMCPServer();
const disabledTools = server.testGetDisabledTools();
const duration = Date.now() - start;
expect(disabledTools.size).toBe(100);
expect(duration).toBeLessThan(50); // Should be very fast
});
it('should handle 1000 disabled tools efficiently', () => {
const manyTools = Array.from({ length: 1000 }, (_, i) => `tool_${i}`);
process.env.DISABLED_TOOLS = manyTools.join(',');
const start = Date.now();
server = new TestableN8NMCPServer();
const disabledTools = server.testGetDisabledTools();
const duration = Date.now() - start;
expect(disabledTools.size).toBe(1000);
expect(duration).toBeLessThan(100); // Should still be fast
});
it('should efficiently check membership in large disabled set', () => {
const manyTools = Array.from({ length: 500 }, (_, i) => `tool_${i}`);
process.env.DISABLED_TOOLS = manyTools.join(',');
server = new TestableN8NMCPServer();
const disabledTools = server.testGetDisabledTools();
// Test membership check performance (Set.has() is O(1))
const start = Date.now();
for (let i = 0; i < 1000; i++) {
disabledTools.has(`tool_${i % 500}`);
}
const duration = Date.now() - start;
expect(duration).toBeLessThan(10); // Should be very fast
});
});
describe('Environment Variable Edge Cases', () => {
it('should handle very long tool names', () => {
const longToolName = 'tool_' + 'a'.repeat(500);
process.env.DISABLED_TOOLS = longToolName;
server = new TestableN8NMCPServer();
const disabledTools = server.testGetDisabledTools();
expect(disabledTools.has(longToolName)).toBe(true);
});
it('should handle newlines in tool names (after trim)', () => {
process.env.DISABLED_TOOLS = 'tool1\n,tool2\r\n,tool3\r';
server = new TestableN8NMCPServer();
const disabledTools = server.testGetDisabledTools();
// Newlines should be trimmed
expect(disabledTools.has('tool1')).toBe(true);
expect(disabledTools.has('tool2')).toBe(true);
expect(disabledTools.has('tool3')).toBe(true);
});
it('should handle tabs in tool names (after trim)', () => {
process.env.DISABLED_TOOLS = '\ttool1\t,\ttool2\t';
server = new TestableN8NMCPServer();
const disabledTools = server.testGetDisabledTools();
expect(disabledTools.has('tool1')).toBe(true);
expect(disabledTools.has('tool2')).toBe(true);
});
it('should handle mixed whitespace correctly', () => {
process.env.DISABLED_TOOLS = ' \t tool1 \n , tool2 \r\n, tool3 ';
server = new TestableN8NMCPServer();
const disabledTools = server.testGetDisabledTools();
expect(disabledTools.size).toBe(3);
expect(disabledTools.has('tool1')).toBe(true);
expect(disabledTools.has('tool2')).toBe(true);
expect(disabledTools.has('tool3')).toBe(true);
});
});
describe('Defense in Depth - Multiple Layers', () => {
it('should prevent execution at executeTool level', async () => {
process.env.DISABLED_TOOLS = 'blocked_tool';
server = new TestableN8NMCPServer();
// The executeTool method should throw immediately
await expect(async () => {
await server.testExecuteTool('blocked_tool', {});
}).rejects.toThrow('disabled via DISABLED_TOOLS');
});
it('should be case-sensitive in tool name matching', async () => {
process.env.DISABLED_TOOLS = 'BlockedTool';
server = new TestableN8NMCPServer();
// 'blockedtool' should NOT be blocked (case-sensitive)
const disabledTools = server.testGetDisabledTools();
expect(disabledTools.has('BlockedTool')).toBe(true);
expect(disabledTools.has('blockedtool')).toBe(false);
});
it('should check disabled status on every executeTool call', async () => {
process.env.DISABLED_TOOLS = 'tool1';
server = new TestableN8NMCPServer();
// First call should fail
await expect(async () => {
await server.testExecuteTool('tool1', {});
}).rejects.toThrow('disabled');
// Second call should also fail (consistent behavior)
await expect(async () => {
await server.testExecuteTool('tool1', {});
}).rejects.toThrow('disabled');
// Non-disabled tool should work (or fail for other reasons)
try {
await server.testExecuteTool('other_tool', {});
} catch (error: any) {
// Should not be disabled error
expect(error.message).not.toContain('disabled via DISABLED_TOOLS');
}
});
});
describe('Real-World Deployment Verification', () => {
it('should support common security hardening scenario', () => {
// Disable all write/delete operations in production
const dangerousTools = [
'n8n_delete_workflow',
'n8n_update_full_workflow',
'n8n_delete_execution',
];
process.env.DISABLED_TOOLS = dangerousTools.join(',');
server = new TestableN8NMCPServer();
const disabledTools = server.testGetDisabledTools();
dangerousTools.forEach(tool => {
expect(disabledTools.has(tool)).toBe(true);
});
});
it('should support staging environment scenario', () => {
// In staging, disable only production-specific tools
process.env.DISABLED_TOOLS = 'n8n_trigger_webhook_workflow';
server = new TestableN8NMCPServer();
const disabledTools = server.testGetDisabledTools();
expect(disabledTools.has('n8n_trigger_webhook_workflow')).toBe(true);
expect(disabledTools.size).toBe(1);
});
it('should support development environment scenario', () => {
// In dev, maybe disable resource-intensive tools
process.env.DISABLED_TOOLS = 'search_templates_by_metadata,fetch_large_datasets';
server = new TestableN8NMCPServer();
const disabledTools = server.testGetDisabledTools();
expect(disabledTools.size).toBe(2);
});
});
});