refactor: Improve DISABLED_TOOLS implementation based on code review

Performance Optimization:
- Add caching to getDisabledTools() to prevent 3x parsing per request
- Cache result as instance property disabledToolsCache
- Reduces overhead from 3x to 1x per server instance

Security Improvements:
- Fix information disclosure in error responses
- Only reveal the attempted tool name, not full list of disabled tools
- Prevents leaking security configuration details

Safety Limits:
- Add 10KB maximum length for DISABLED_TOOLS environment variable
- Add 200-tool maximum limit to prevent abuse
- Include warnings when limits are exceeded

Code Quality:
- Add clarifying comment for defense-in-depth guard in executeTool()
- Change logging level from info to debug for frequent operations
- Add comprehensive JSDoc to TestableN8NMCPServer test classes
- Document test wrapper pattern and exposed methods

Test Updates:
- Update test to verify 200-tool safety limit enforcement
- All 45 tests passing with improved coverage

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
czlonkowski
2025-11-09 17:00:23 +01:00
parent 53252adc68
commit 821ace310e
3 changed files with 66 additions and 14 deletions

View File

@@ -70,6 +70,7 @@ export class N8NDocumentationMCPServer {
private previousTool: string | null = null; private previousTool: string | null = null;
private previousToolTimestamp: number = Date.now(); private previousToolTimestamp: number = Date.now();
private earlyLogger: EarlyErrorLogger | null = null; private earlyLogger: EarlyErrorLogger | null = null;
private disabledToolsCache: Set<string> | null = null;
constructor(instanceContext?: InstanceContext, earlyLogger?: EarlyErrorLogger) { constructor(instanceContext?: InstanceContext, earlyLogger?: EarlyErrorLogger) {
this.instanceContext = instanceContext; this.instanceContext = instanceContext;
@@ -327,24 +328,46 @@ export class N8NDocumentationMCPServer {
* Parse and cache disabled tools from DISABLED_TOOLS environment variable. * Parse and cache disabled tools from DISABLED_TOOLS environment variable.
* Returns a Set of tool names that should be filtered from registration. * Returns a Set of tool names that should be filtered from registration.
* *
* Cached after first call since environment variables don't change at runtime.
* Includes safety limits: max 10KB env var length, max 200 tools.
*
* @returns Set of disabled tool names * @returns Set of disabled tool names
*/ */
private getDisabledTools(): Set<string> { private getDisabledTools(): Set<string> {
const disabledToolsEnv = process.env.DISABLED_TOOLS || ''; // Return cached value if available
if (!disabledToolsEnv) { if (this.disabledToolsCache !== null) {
return new Set(); return this.disabledToolsCache;
} }
const tools = disabledToolsEnv let disabledToolsEnv = process.env.DISABLED_TOOLS || '';
if (!disabledToolsEnv) {
this.disabledToolsCache = new Set();
return this.disabledToolsCache;
}
// Safety limit: prevent abuse with very long environment variables
if (disabledToolsEnv.length > 10000) {
logger.warn(`DISABLED_TOOLS environment variable too long (${disabledToolsEnv.length} chars), truncating to 10000`);
disabledToolsEnv = disabledToolsEnv.substring(0, 10000);
}
let tools = disabledToolsEnv
.split(',') .split(',')
.map(t => t.trim()) .map(t => t.trim())
.filter(Boolean); .filter(Boolean);
// Safety limit: prevent abuse with too many tools
if (tools.length > 200) {
logger.warn(`DISABLED_TOOLS contains ${tools.length} tools, limiting to first 200`);
tools = tools.slice(0, 200);
}
if (tools.length > 0) { if (tools.length > 0) {
logger.info(`Disabled tools configured: ${tools.join(', ')}`); logger.info(`Disabled tools configured: ${tools.join(', ')}`);
} }
return new Set(tools); this.disabledToolsCache = new Set(tools);
return this.disabledToolsCache;
} }
private setupHandlers(): void { private setupHandlers(): void {
@@ -445,7 +468,7 @@ export class N8NDocumentationMCPServer {
// Log filtered tools count if any tools are disabled // Log filtered tools count if any tools are disabled
if (disabledTools.size > 0) { if (disabledTools.size > 0) {
const totalAvailableTools = n8nDocumentationToolsFinal.length + (shouldIncludeManagementTools ? n8nManagementTools.length : 0); const totalAvailableTools = n8nDocumentationToolsFinal.length + (shouldIncludeManagementTools ? n8nManagementTools.length : 0);
logger.info(`Filtered ${disabledTools.size} disabled tools, ${tools.length}/${totalAvailableTools} tools available`); logger.debug(`Filtered ${disabledTools.size} disabled tools, ${tools.length}/${totalAvailableTools} tools available`);
} }
// Check if client is n8n (from initialization) // Check if client is n8n (from initialization)
@@ -498,7 +521,7 @@ export class N8NDocumentationMCPServer {
text: JSON.stringify({ text: JSON.stringify({
error: 'TOOL_DISABLED', error: 'TOOL_DISABLED',
message: `Tool '${name}' is not available in this deployment. It has been disabled via DISABLED_TOOLS environment variable.`, message: `Tool '${name}' is not available in this deployment. It has been disabled via DISABLED_TOOLS environment variable.`,
disabledTools: Array.from(disabledTools) tool: name
}, null, 2) }, null, 2)
}] }]
}; };
@@ -906,7 +929,9 @@ export class N8NDocumentationMCPServer {
// Ensure args is an object and validate it // Ensure args is an object and validate it
args = args || {}; args = args || {};
// Safety check for disabled tools (defense in depth) // Defense in depth: This should never be reached since CallToolRequestSchema
// handler already checks disabled tools (line 514-528), but we guard here
// in case of future refactoring or direct executeTool() calls
const disabledTools = this.getDisabledTools(); const disabledTools = this.getDisabledTools();
if (disabledTools.has(name)) { if (disabledTools.has(name)) {
throw new Error(`Tool '${name}' is disabled via DISABLED_TOOLS environment variable`); throw new Error(`Tool '${name}' is disabled via DISABLED_TOOLS environment variable`);

View File

@@ -7,13 +7,26 @@ vi.mock('../../../src/database/node-repository');
vi.mock('../../../src/templates/template-service'); vi.mock('../../../src/templates/template-service');
vi.mock('../../../src/utils/logger'); vi.mock('../../../src/utils/logger');
/**
* Test wrapper class that exposes private methods for unit testing.
* This pattern is preferred over modifying production code visibility
* or using reflection-based testing utilities.
*/
class TestableN8NMCPServer extends N8NDocumentationMCPServer { class TestableN8NMCPServer extends N8NDocumentationMCPServer {
// Expose the private getDisabledTools method for testing /**
* Expose getDisabledTools() for testing environment variable parsing.
* @returns Set of disabled tool names from DISABLED_TOOLS env var
*/
public testGetDisabledTools(): Set<string> { public testGetDisabledTools(): Set<string> {
return (this as any).getDisabledTools(); return (this as any).getDisabledTools();
} }
// Expose the private executeTool method for testing /**
* Expose executeTool() for testing the defense-in-depth guard.
* @param name - Tool name to execute
* @param args - Tool arguments
* @returns Tool execution result
*/
public async testExecuteTool(name: string, args: any): Promise<any> { public async testExecuteTool(name: string, args: any): Promise<any> {
return (this as any).executeTool(name, args); return (this as any).executeTool(name, args);
} }
@@ -198,7 +211,7 @@ describe('Disabled Tools Additional Coverage (Issue #410)', () => {
expect(duration).toBeLessThan(50); // Should be very fast expect(duration).toBeLessThan(50); // Should be very fast
}); });
it('should handle 1000 disabled tools efficiently', () => { it('should handle 1000 disabled tools efficiently and enforce 200 tool limit', () => {
const manyTools = Array.from({ length: 1000 }, (_, i) => `tool_${i}`); const manyTools = Array.from({ length: 1000 }, (_, i) => `tool_${i}`);
process.env.DISABLED_TOOLS = manyTools.join(','); process.env.DISABLED_TOOLS = manyTools.join(',');
@@ -207,7 +220,8 @@ describe('Disabled Tools Additional Coverage (Issue #410)', () => {
const disabledTools = server.testGetDisabledTools(); const disabledTools = server.testGetDisabledTools();
const duration = Date.now() - start; const duration = Date.now() - start;
expect(disabledTools.size).toBe(1000); // Safety limit: max 200 tools enforced
expect(disabledTools.size).toBe(200);
expect(duration).toBeLessThan(100); // Should still be fast expect(duration).toBeLessThan(100); // Should still be fast
}); });

View File

@@ -9,13 +9,26 @@ vi.mock('../../../src/database/node-repository');
vi.mock('../../../src/templates/template-service'); vi.mock('../../../src/templates/template-service');
vi.mock('../../../src/utils/logger'); vi.mock('../../../src/utils/logger');
/**
* Test wrapper class that exposes private methods for unit testing.
* This pattern is preferred over modifying production code visibility
* or using reflection-based testing utilities.
*/
class TestableN8NMCPServer extends N8NDocumentationMCPServer { class TestableN8NMCPServer extends N8NDocumentationMCPServer {
// Expose the private getDisabledTools method for testing /**
* Expose getDisabledTools() for testing environment variable parsing.
* @returns Set of disabled tool names from DISABLED_TOOLS env var
*/
public testGetDisabledTools(): Set<string> { public testGetDisabledTools(): Set<string> {
return (this as any).getDisabledTools(); return (this as any).getDisabledTools();
} }
// Expose the private executeTool method for testing /**
* Expose executeTool() for testing the defense-in-depth guard.
* @param name - Tool name to execute
* @param args - Tool arguments
* @returns Tool execution result
*/
public async testExecuteTool(name: string, args: any): Promise<any> { public async testExecuteTool(name: string, args: any): Promise<any> {
return (this as any).executeTool(name, args); return (this as any).executeTool(name, args);
} }