Files
n8n-mcp/src/mcp/server.ts
czlonkowski 738f0ad86d feat: Add MCP server icon support (SEP-973) v2.20.0
Implements custom server icons for MCP clients according to the MCP
specification SEP-973. Icons enable better visual identification of
the n8n-mcp server in MCP client interfaces.

Features:
- Added 3 icon sizes: 192x192, 128x128, 48x48 (PNG format)
- Icons served from https://www.n8n-mcp.com/logo*.png
- Added websiteUrl field pointing to https://n8n-mcp.com
- Server version now uses package.json (PROJECT_VERSION) instead of hardcoded '1.0.0'

Changes:
- Upgraded @modelcontextprotocol/sdk from ^1.13.2 to ^1.20.1
- Updated src/mcp/server.ts with icon configuration
- Bumped version to 2.20.0
- Updated CHANGELOG.md with release notes

Testing:
- All icon URLs verified accessible (HTTP 200, CORS enabled)
- Build passes, type checking passes
- No breaking changes, fully backward compatible

Icons won't display in Claude Desktop yet (pending upstream UI support),
but will appear automatically when support is added. Other MCP clients
may already support icon display.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 18:28:32 +02:00

3170 lines
112 KiB
TypeScript

import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
InitializeRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { existsSync, promises as fs } from 'fs';
import path from 'path';
import { n8nDocumentationToolsFinal } from './tools';
import { n8nManagementTools } from './tools-n8n-manager';
import { makeToolsN8nFriendly } from './tools-n8n-friendly';
import { getWorkflowExampleString } from './workflow-examples';
import { logger } from '../utils/logger';
import { NodeRepository } from '../database/node-repository';
import { DatabaseAdapter, createDatabaseAdapter } from '../database/database-adapter';
import { PropertyFilter } from '../services/property-filter';
import { TaskTemplates } from '../services/task-templates';
import { ConfigValidator } from '../services/config-validator';
import { EnhancedConfigValidator, ValidationMode, ValidationProfile } from '../services/enhanced-config-validator';
import { PropertyDependencies } from '../services/property-dependencies';
import { SimpleCache } from '../utils/simple-cache';
import { TemplateService } from '../templates/template-service';
import { WorkflowValidator } from '../services/workflow-validator';
import { isN8nApiConfigured } from '../config/n8n-api';
import * as n8nHandlers from './handlers-n8n-manager';
import { handleUpdatePartialWorkflow } from './handlers-workflow-diff';
import { getToolDocumentation, getToolsOverview } from './tools-documentation';
import { PROJECT_VERSION } from '../utils/version';
import { getNodeTypeAlternatives, getWorkflowNodeType } from '../utils/node-utils';
import { NodeTypeNormalizer } from '../utils/node-type-normalizer';
import { ToolValidation, Validator, ValidationError } from '../utils/validation-schemas';
import {
negotiateProtocolVersion,
logProtocolNegotiation,
STANDARD_PROTOCOL_VERSION
} from '../utils/protocol-version';
import { InstanceContext } from '../types/instance-context';
import { telemetry } from '../telemetry';
import { EarlyErrorLogger } from '../telemetry/early-error-logger';
import { STARTUP_CHECKPOINTS } from '../telemetry/startup-checkpoints';
interface NodeRow {
node_type: string;
package_name: string;
display_name: string;
description?: string;
category?: string;
development_style?: string;
is_ai_tool: number;
is_trigger: number;
is_webhook: number;
is_versioned: number;
version?: string;
documentation?: string;
properties_schema?: string;
operations?: string;
credentials_required?: string;
}
export class N8NDocumentationMCPServer {
private server: Server;
private db: DatabaseAdapter | null = null;
private repository: NodeRepository | null = null;
private templateService: TemplateService | null = null;
private initialized: Promise<void>;
private cache = new SimpleCache();
private clientInfo: any = null;
private instanceContext?: InstanceContext;
private previousTool: string | null = null;
private previousToolTimestamp: number = Date.now();
private earlyLogger: EarlyErrorLogger | null = null;
constructor(instanceContext?: InstanceContext, earlyLogger?: EarlyErrorLogger) {
this.instanceContext = instanceContext;
this.earlyLogger = earlyLogger || null;
// Check for test environment first
const envDbPath = process.env.NODE_DB_PATH;
let dbPath: string | null = null;
let possiblePaths: string[] = [];
if (envDbPath && (envDbPath === ':memory:' || existsSync(envDbPath))) {
dbPath = envDbPath;
} else {
// Try multiple database paths
possiblePaths = [
path.join(process.cwd(), 'data', 'nodes.db'),
path.join(__dirname, '../../data', 'nodes.db'),
'./data/nodes.db'
];
for (const p of possiblePaths) {
if (existsSync(p)) {
dbPath = p;
break;
}
}
}
if (!dbPath) {
logger.error('Database not found in any of the expected locations:', possiblePaths);
throw new Error('Database nodes.db not found. Please run npm run rebuild first.');
}
// Initialize database asynchronously
this.initialized = this.initializeDatabase(dbPath).then(() => {
// After database is ready, check n8n API configuration (v2.18.3)
if (this.earlyLogger) {
this.earlyLogger.logCheckpoint(STARTUP_CHECKPOINTS.N8N_API_CHECKING);
}
// Log n8n API configuration status at startup
const apiConfigured = isN8nApiConfigured();
const totalTools = apiConfigured ?
n8nDocumentationToolsFinal.length + n8nManagementTools.length :
n8nDocumentationToolsFinal.length;
logger.info(`MCP server initialized with ${totalTools} tools (n8n API: ${apiConfigured ? 'configured' : 'not configured'})`);
if (this.earlyLogger) {
this.earlyLogger.logCheckpoint(STARTUP_CHECKPOINTS.N8N_API_READY);
}
});
logger.info('Initializing n8n Documentation MCP server');
this.server = new Server(
{
name: 'n8n-documentation-mcp',
version: PROJECT_VERSION,
icons: [
{
src: "https://www.n8n-mcp.com/logo.png",
mimeType: "image/png",
sizes: ["192x192"]
},
{
src: "https://www.n8n-mcp.com/logo-128.png",
mimeType: "image/png",
sizes: ["128x128"]
},
{
src: "https://www.n8n-mcp.com/logo-48.png",
mimeType: "image/png",
sizes: ["48x48"]
}
],
websiteUrl: "https://n8n-mcp.com"
},
{
capabilities: {
tools: {},
},
}
);
this.setupHandlers();
}
private async initializeDatabase(dbPath: string): Promise<void> {
try {
// Checkpoint: Database connecting (v2.18.3)
if (this.earlyLogger) {
this.earlyLogger.logCheckpoint(STARTUP_CHECKPOINTS.DATABASE_CONNECTING);
}
logger.debug('Database initialization starting...', { dbPath });
this.db = await createDatabaseAdapter(dbPath);
logger.debug('Database adapter created');
// If using in-memory database for tests, initialize schema
if (dbPath === ':memory:') {
await this.initializeInMemorySchema();
logger.debug('In-memory schema initialized');
}
this.repository = new NodeRepository(this.db);
logger.debug('Node repository initialized');
this.templateService = new TemplateService(this.db);
logger.debug('Template service initialized');
// Initialize similarity services for enhanced validation
EnhancedConfigValidator.initializeSimilarityServices(this.repository);
logger.debug('Similarity services initialized');
// Checkpoint: Database connected (v2.18.3)
if (this.earlyLogger) {
this.earlyLogger.logCheckpoint(STARTUP_CHECKPOINTS.DATABASE_CONNECTED);
}
logger.info(`Database initialized successfully from: ${dbPath}`);
} catch (error) {
logger.error('Failed to initialize database:', error);
throw new Error(`Failed to open database: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private async initializeInMemorySchema(): Promise<void> {
if (!this.db) return;
// Read and execute schema
const schemaPath = path.join(__dirname, '../../src/database/schema.sql');
const schema = await fs.readFile(schemaPath, 'utf-8');
// Parse SQL statements properly (handles BEGIN...END blocks in triggers)
const statements = this.parseSQLStatements(schema);
for (const statement of statements) {
if (statement.trim()) {
try {
this.db.exec(statement);
} catch (error) {
logger.error(`Failed to execute SQL statement: ${statement.substring(0, 100)}...`, error);
throw error;
}
}
}
}
/**
* Parse SQL statements from schema file, properly handling multi-line statements
* including triggers with BEGIN...END blocks
*/
private parseSQLStatements(sql: string): string[] {
const statements: string[] = [];
let current = '';
let inBlock = false;
const lines = sql.split('\n');
for (const line of lines) {
const trimmed = line.trim().toUpperCase();
// Skip comments and empty lines
if (trimmed.startsWith('--') || trimmed === '') {
continue;
}
// Track BEGIN...END blocks (triggers, procedures)
if (trimmed.includes('BEGIN')) {
inBlock = true;
}
current += line + '\n';
// End of block (trigger/procedure)
if (inBlock && trimmed === 'END;') {
statements.push(current.trim());
current = '';
inBlock = false;
continue;
}
// Regular statement end (not in block)
if (!inBlock && trimmed.endsWith(';')) {
statements.push(current.trim());
current = '';
}
}
// Add any remaining content
if (current.trim()) {
statements.push(current.trim());
}
return statements.filter(s => s.length > 0);
}
private async ensureInitialized(): Promise<void> {
await this.initialized;
if (!this.db || !this.repository) {
throw new Error('Database not initialized');
}
// Validate database health on first access
if (!this.dbHealthChecked) {
await this.validateDatabaseHealth();
this.dbHealthChecked = true;
}
}
private dbHealthChecked: boolean = false;
private async validateDatabaseHealth(): Promise<void> {
if (!this.db) return;
try {
// Check if nodes table has data
const nodeCount = this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as { count: number };
if (nodeCount.count === 0) {
logger.error('CRITICAL: Database is empty - no nodes found! Please run: npm run rebuild');
throw new Error('Database is empty. Run "npm run rebuild" to populate node data.');
}
// Check if FTS5 table exists
const ftsExists = this.db.prepare(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='nodes_fts'
`).get();
if (!ftsExists) {
logger.warn('FTS5 table missing - search performance will be degraded. Please run: npm run rebuild');
} else {
const ftsCount = this.db.prepare('SELECT COUNT(*) as count FROM nodes_fts').get() as { count: number };
if (ftsCount.count === 0) {
logger.warn('FTS5 index is empty - search will not work properly. Please run: npm run rebuild');
}
}
logger.info(`Database health check passed: ${nodeCount.count} nodes loaded`);
} catch (error) {
logger.error('Database health check failed:', error);
throw error;
}
}
private setupHandlers(): void {
// Handle initialization
this.server.setRequestHandler(InitializeRequestSchema, async (request) => {
const clientVersion = request.params.protocolVersion;
const clientCapabilities = request.params.capabilities;
const clientInfo = request.params.clientInfo;
logger.info('MCP Initialize request received', {
clientVersion,
clientCapabilities,
clientInfo
});
// Track session start
telemetry.trackSessionStart();
// Store client info for later use
this.clientInfo = clientInfo;
// Negotiate protocol version based on client information
const negotiationResult = negotiateProtocolVersion(
clientVersion,
clientInfo,
undefined, // no user agent in MCP protocol
undefined // no headers in MCP protocol
);
logProtocolNegotiation(negotiationResult, logger, 'MCP_INITIALIZE');
// Warn if there's a version mismatch (for debugging)
if (clientVersion && clientVersion !== negotiationResult.version) {
logger.warn(`Protocol version negotiated: client requested ${clientVersion}, server will use ${negotiationResult.version}`, {
reasoning: negotiationResult.reasoning
});
}
const response = {
protocolVersion: negotiationResult.version,
capabilities: {
tools: {},
},
serverInfo: {
name: 'n8n-documentation-mcp',
version: PROJECT_VERSION,
},
};
logger.info('MCP Initialize response', { response });
return response;
});
// Handle tool listing
this.server.setRequestHandler(ListToolsRequestSchema, async (request) => {
// Combine documentation tools with management tools if API is configured
let tools = [...n8nDocumentationToolsFinal];
// Check if n8n API tools should be available
// 1. Environment variables (backward compatibility)
// 2. Instance context (multi-tenant support)
// 3. Multi-tenant mode enabled (always show tools, runtime checks will handle auth)
const hasEnvConfig = isN8nApiConfigured();
const hasInstanceConfig = !!(this.instanceContext?.n8nApiUrl && this.instanceContext?.n8nApiKey);
const isMultiTenantEnabled = process.env.ENABLE_MULTI_TENANT === 'true';
const shouldIncludeManagementTools = hasEnvConfig || hasInstanceConfig || isMultiTenantEnabled;
if (shouldIncludeManagementTools) {
tools.push(...n8nManagementTools);
logger.debug(`Tool listing: ${tools.length} tools available (${n8nDocumentationToolsFinal.length} documentation + ${n8nManagementTools.length} management)`, {
hasEnvConfig,
hasInstanceConfig,
isMultiTenantEnabled
});
} else {
logger.debug(`Tool listing: ${tools.length} tools available (documentation only)`, {
hasEnvConfig,
hasInstanceConfig,
isMultiTenantEnabled
});
}
// Check if client is n8n (from initialization)
const clientInfo = this.clientInfo;
const isN8nClient = clientInfo?.name?.includes('n8n') ||
clientInfo?.name?.includes('langchain');
if (isN8nClient) {
logger.info('Detected n8n client, using n8n-friendly tool descriptions');
tools = makeToolsN8nFriendly(tools);
}
// Log validation tools' input schemas for debugging
const validationTools = tools.filter(t => t.name.startsWith('validate_'));
validationTools.forEach(tool => {
logger.info('Validation tool schema', {
toolName: tool.name,
inputSchema: JSON.stringify(tool.inputSchema, null, 2),
hasOutputSchema: !!tool.outputSchema,
description: tool.description
});
});
return { tools };
});
// Handle tool execution
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
// Enhanced logging for debugging tool calls
logger.info('Tool call received - DETAILED DEBUG', {
toolName: name,
arguments: JSON.stringify(args, null, 2),
argumentsType: typeof args,
argumentsKeys: args ? Object.keys(args) : [],
hasNodeType: args && 'nodeType' in args,
hasConfig: args && 'config' in args,
configType: args && args.config ? typeof args.config : 'N/A',
rawRequest: JSON.stringify(request.params)
});
// Workaround for n8n's nested output bug
// Check if args contains nested 'output' structure from n8n's memory corruption
let processedArgs = args;
if (args && typeof args === 'object' && 'output' in args) {
try {
const possibleNestedData = args.output;
// If output is a string that looks like JSON, try to parse it
if (typeof possibleNestedData === 'string' && possibleNestedData.trim().startsWith('{')) {
const parsed = JSON.parse(possibleNestedData);
if (parsed && typeof parsed === 'object') {
logger.warn('Detected n8n nested output bug, attempting to extract actual arguments', {
originalArgs: args,
extractedArgs: parsed
});
// Validate the extracted arguments match expected tool schema
if (this.validateExtractedArgs(name, parsed)) {
// Use the extracted data as args
processedArgs = parsed;
} else {
logger.warn('Extracted arguments failed validation, using original args', {
toolName: name,
extractedArgs: parsed
});
}
}
}
} catch (parseError) {
logger.debug('Failed to parse nested output, continuing with original args', {
error: parseError instanceof Error ? parseError.message : String(parseError)
});
}
}
try {
logger.debug(`Executing tool: ${name}`, { args: processedArgs });
const startTime = Date.now();
const result = await this.executeTool(name, processedArgs);
const duration = Date.now() - startTime;
logger.debug(`Tool ${name} executed successfully`);
// Track tool usage and sequence
telemetry.trackToolUsage(name, true, duration);
// Track tool sequence if there was a previous tool
if (this.previousTool) {
const timeDelta = Date.now() - this.previousToolTimestamp;
telemetry.trackToolSequence(this.previousTool, name, timeDelta);
}
// Update previous tool tracking
this.previousTool = name;
this.previousToolTimestamp = Date.now();
// Ensure the result is properly formatted for MCP
let responseText: string;
let structuredContent: any = null;
try {
// For validation tools, check if we should use structured content
if (name.startsWith('validate_') && typeof result === 'object' && result !== null) {
// Clean up the result to ensure it matches the outputSchema
const cleanResult = this.sanitizeValidationResult(result, name);
structuredContent = cleanResult;
responseText = JSON.stringify(cleanResult, null, 2);
} else {
responseText = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
}
} catch (jsonError) {
logger.warn(`Failed to stringify tool result for ${name}:`, jsonError);
responseText = String(result);
}
// Validate response size (n8n might have limits)
if (responseText.length > 1000000) { // 1MB limit
logger.warn(`Tool ${name} response is very large (${responseText.length} chars), truncating`);
responseText = responseText.substring(0, 999000) + '\n\n[Response truncated due to size limits]';
structuredContent = null; // Don't use structured content for truncated responses
}
// Build MCP response with strict schema compliance
const mcpResponse: any = {
content: [
{
type: 'text' as const,
text: responseText,
},
],
};
// For tools with outputSchema, structuredContent is REQUIRED by MCP spec
if (name.startsWith('validate_') && structuredContent !== null) {
mcpResponse.structuredContent = structuredContent;
}
return mcpResponse;
} catch (error) {
logger.error(`Error executing tool ${name}`, error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
// Track tool error
telemetry.trackToolUsage(name, false);
telemetry.trackError(
error instanceof Error ? error.constructor.name : 'UnknownError',
`tool_execution`,
name,
errorMessage
);
// Track tool sequence even for errors
if (this.previousTool) {
const timeDelta = Date.now() - this.previousToolTimestamp;
telemetry.trackToolSequence(this.previousTool, name, timeDelta);
}
// Update previous tool tracking (even for failed tools)
this.previousTool = name;
this.previousToolTimestamp = Date.now();
// Provide more helpful error messages for common n8n issues
let helpfulMessage = `Error executing tool ${name}: ${errorMessage}`;
if (errorMessage.includes('required') || errorMessage.includes('missing')) {
helpfulMessage += '\n\nNote: This error often occurs when the AI agent sends incomplete or incorrectly formatted parameters. Please ensure all required fields are provided with the correct types.';
} else if (errorMessage.includes('type') || errorMessage.includes('expected')) {
helpfulMessage += '\n\nNote: This error indicates a type mismatch. The AI agent may be sending data in the wrong format (e.g., string instead of object).';
} else if (errorMessage.includes('Unknown category') || errorMessage.includes('not found')) {
helpfulMessage += '\n\nNote: The requested resource or category was not found. Please check the available options.';
}
// For n8n schema errors, add specific guidance
if (name.startsWith('validate_') && (errorMessage.includes('config') || errorMessage.includes('nodeType'))) {
helpfulMessage += '\n\nFor validation tools:\n- nodeType should be a string (e.g., "nodes-base.webhook")\n- config should be an object (e.g., {})';
}
return {
content: [
{
type: 'text',
text: helpfulMessage,
},
],
isError: true,
};
}
});
}
/**
* Sanitize validation result to match outputSchema
*/
private sanitizeValidationResult(result: any, toolName: string): any {
if (!result || typeof result !== 'object') {
return result;
}
const sanitized = { ...result };
// Ensure required fields exist with proper types and filter to schema-defined fields only
if (toolName === 'validate_node_minimal') {
// Filter to only schema-defined fields
const filtered = {
nodeType: String(sanitized.nodeType || ''),
displayName: String(sanitized.displayName || ''),
valid: Boolean(sanitized.valid),
missingRequiredFields: Array.isArray(sanitized.missingRequiredFields)
? sanitized.missingRequiredFields.map(String)
: []
};
return filtered;
} else if (toolName === 'validate_node_operation') {
// Ensure summary exists
let summary = sanitized.summary;
if (!summary || typeof summary !== 'object') {
summary = {
hasErrors: Array.isArray(sanitized.errors) ? sanitized.errors.length > 0 : false,
errorCount: Array.isArray(sanitized.errors) ? sanitized.errors.length : 0,
warningCount: Array.isArray(sanitized.warnings) ? sanitized.warnings.length : 0,
suggestionCount: Array.isArray(sanitized.suggestions) ? sanitized.suggestions.length : 0
};
}
// Filter to only schema-defined fields
const filtered = {
nodeType: String(sanitized.nodeType || ''),
workflowNodeType: String(sanitized.workflowNodeType || sanitized.nodeType || ''),
displayName: String(sanitized.displayName || ''),
valid: Boolean(sanitized.valid),
errors: Array.isArray(sanitized.errors) ? sanitized.errors : [],
warnings: Array.isArray(sanitized.warnings) ? sanitized.warnings : [],
suggestions: Array.isArray(sanitized.suggestions) ? sanitized.suggestions : [],
summary: summary
};
return filtered;
} else if (toolName.startsWith('validate_workflow')) {
sanitized.valid = Boolean(sanitized.valid);
// Ensure arrays exist
sanitized.errors = Array.isArray(sanitized.errors) ? sanitized.errors : [];
sanitized.warnings = Array.isArray(sanitized.warnings) ? sanitized.warnings : [];
// Ensure statistics/summary exists
if (toolName === 'validate_workflow') {
if (!sanitized.summary || typeof sanitized.summary !== 'object') {
sanitized.summary = {
totalNodes: 0,
enabledNodes: 0,
triggerNodes: 0,
validConnections: 0,
invalidConnections: 0,
expressionsValidated: 0,
errorCount: sanitized.errors.length,
warningCount: sanitized.warnings.length
};
}
} else {
if (!sanitized.statistics || typeof sanitized.statistics !== 'object') {
sanitized.statistics = {
totalNodes: 0,
triggerNodes: 0,
validConnections: 0,
invalidConnections: 0,
expressionsValidated: 0
};
}
}
}
// Remove undefined values to ensure clean JSON
return JSON.parse(JSON.stringify(sanitized));
}
/**
* Enhanced parameter validation using schemas
*/
private validateToolParams(toolName: string, args: any, legacyRequiredParams?: string[]): void {
try {
// If legacy required params are provided, use the new validation but fall back to basic if needed
let validationResult;
switch (toolName) {
case 'validate_node_operation':
validationResult = ToolValidation.validateNodeOperation(args);
break;
case 'validate_node_minimal':
validationResult = ToolValidation.validateNodeMinimal(args);
break;
case 'validate_workflow':
case 'validate_workflow_connections':
case 'validate_workflow_expressions':
validationResult = ToolValidation.validateWorkflow(args);
break;
case 'search_nodes':
validationResult = ToolValidation.validateSearchNodes(args);
break;
case 'list_node_templates':
validationResult = ToolValidation.validateListNodeTemplates(args);
break;
case 'n8n_create_workflow':
validationResult = ToolValidation.validateCreateWorkflow(args);
break;
case 'n8n_get_workflow':
case 'n8n_get_workflow_details':
case 'n8n_get_workflow_structure':
case 'n8n_get_workflow_minimal':
case 'n8n_update_full_workflow':
case 'n8n_delete_workflow':
case 'n8n_validate_workflow':
case 'n8n_autofix_workflow':
case 'n8n_get_execution':
case 'n8n_delete_execution':
validationResult = ToolValidation.validateWorkflowId(args);
break;
default:
// For tools not yet migrated to schema validation, use basic validation
return this.validateToolParamsBasic(toolName, args, legacyRequiredParams || []);
}
if (!validationResult.valid) {
const errorMessage = Validator.formatErrors(validationResult, toolName);
logger.error(`Parameter validation failed for ${toolName}:`, errorMessage);
throw new ValidationError(errorMessage);
}
} catch (error) {
// Handle validation errors properly
if (error instanceof ValidationError) {
throw error; // Re-throw validation errors as-is
}
// Handle unexpected errors from validation system
logger.error(`Validation system error for ${toolName}:`, error);
// Provide a user-friendly error message
const errorMessage = error instanceof Error
? `Internal validation error: ${error.message}`
: `Internal validation error while processing ${toolName}`;
throw new Error(errorMessage);
}
}
/**
* Legacy parameter validation (fallback)
*/
private validateToolParamsBasic(toolName: string, args: any, requiredParams: string[]): void {
const missing: string[] = [];
const invalid: string[] = [];
for (const param of requiredParams) {
if (!(param in args) || args[param] === undefined || args[param] === null) {
missing.push(param);
} else if (typeof args[param] === 'string' && args[param].trim() === '') {
invalid.push(`${param} (empty string)`);
}
}
if (missing.length > 0) {
throw new Error(`Missing required parameters for ${toolName}: ${missing.join(', ')}. Please provide the required parameters to use this tool.`);
}
if (invalid.length > 0) {
throw new Error(`Invalid parameters for ${toolName}: ${invalid.join(', ')}. String parameters cannot be empty.`);
}
}
/**
* Validate extracted arguments match expected tool schema
*/
private validateExtractedArgs(toolName: string, args: any): boolean {
if (!args || typeof args !== 'object') {
return false;
}
// Get all available tools
const allTools = [...n8nDocumentationToolsFinal, ...n8nManagementTools];
const tool = allTools.find(t => t.name === toolName);
if (!tool || !tool.inputSchema) {
return true; // If no schema, assume valid
}
const schema = tool.inputSchema;
const required = schema.required || [];
const properties = schema.properties || {};
// Check all required fields are present
for (const requiredField of required) {
if (!(requiredField in args)) {
logger.debug(`Extracted args missing required field: ${requiredField}`, {
toolName,
extractedArgs: args,
required
});
return false;
}
}
// Check field types match schema
for (const [fieldName, fieldValue] of Object.entries(args)) {
if (properties[fieldName]) {
const expectedType = properties[fieldName].type;
const actualType = Array.isArray(fieldValue) ? 'array' : typeof fieldValue;
// Basic type validation
if (expectedType && expectedType !== actualType) {
// Special case: number can be coerced from string
if (expectedType === 'number' && actualType === 'string' && !isNaN(Number(fieldValue))) {
continue;
}
logger.debug(`Extracted args field type mismatch: ${fieldName}`, {
toolName,
expectedType,
actualType,
fieldValue
});
return false;
}
}
}
// Check for extraneous fields if additionalProperties is false
if (schema.additionalProperties === false) {
const allowedFields = Object.keys(properties);
const extraFields = Object.keys(args).filter(field => !allowedFields.includes(field));
if (extraFields.length > 0) {
logger.debug(`Extracted args have extra fields`, {
toolName,
extraFields,
allowedFields
});
// For n8n compatibility, we'll still consider this valid but log it
}
}
return true;
}
async executeTool(name: string, args: any): Promise<any> {
// Ensure args is an object and validate it
args = args || {};
// Log the tool call for debugging n8n issues
logger.info(`Tool execution: ${name}`, {
args: typeof args === 'object' ? JSON.stringify(args) : args,
argsType: typeof args,
argsKeys: typeof args === 'object' ? Object.keys(args) : 'not-object'
});
// Validate that args is actually an object
if (typeof args !== 'object' || args === null) {
throw new Error(`Invalid arguments for tool ${name}: expected object, got ${typeof args}`);
}
switch (name) {
case 'tools_documentation':
// No required parameters
return this.getToolsDocumentation(args.topic, args.depth);
case 'list_nodes':
// No required parameters
return this.listNodes(args);
case 'get_node_info':
this.validateToolParams(name, args, ['nodeType']);
return this.getNodeInfo(args.nodeType);
case 'search_nodes':
this.validateToolParams(name, args, ['query']);
// Convert limit to number if provided, otherwise use default
const limit = args.limit !== undefined ? Number(args.limit) || 20 : 20;
return this.searchNodes(args.query, limit, { mode: args.mode, includeExamples: args.includeExamples });
case 'list_ai_tools':
// No required parameters
return this.listAITools();
case 'get_node_documentation':
this.validateToolParams(name, args, ['nodeType']);
return this.getNodeDocumentation(args.nodeType);
case 'get_database_statistics':
// No required parameters
return this.getDatabaseStatistics();
case 'get_node_essentials':
this.validateToolParams(name, args, ['nodeType']);
return this.getNodeEssentials(args.nodeType, args.includeExamples);
case 'search_node_properties':
this.validateToolParams(name, args, ['nodeType', 'query']);
const maxResults = args.maxResults !== undefined ? Number(args.maxResults) || 20 : 20;
return this.searchNodeProperties(args.nodeType, args.query, maxResults);
case 'list_tasks':
// No required parameters
return this.listTasks(args.category);
case 'validate_node_operation':
this.validateToolParams(name, args, ['nodeType', 'config']);
// Ensure config is an object
if (typeof args.config !== 'object' || args.config === null) {
logger.warn(`validate_node_operation called with invalid config type: ${typeof args.config}`);
return {
nodeType: args.nodeType || 'unknown',
workflowNodeType: args.nodeType || 'unknown',
displayName: 'Unknown Node',
valid: false,
errors: [{
type: 'config',
property: 'config',
message: 'Invalid config format - expected object',
fix: 'Provide config as an object with node properties'
}],
warnings: [],
suggestions: [
'🔧 RECOVERY: Invalid config detected. Fix with:',
' • Ensure config is an object: { "resource": "...", "operation": "..." }',
' • Use get_node_essentials to see required fields for this node type',
' • Check if the node type is correct before configuring it'
],
summary: {
hasErrors: true,
errorCount: 1,
warningCount: 0,
suggestionCount: 3
}
};
}
return this.validateNodeConfig(args.nodeType, args.config, 'operation', args.profile);
case 'validate_node_minimal':
this.validateToolParams(name, args, ['nodeType', 'config']);
// Ensure config is an object
if (typeof args.config !== 'object' || args.config === null) {
logger.warn(`validate_node_minimal called with invalid config type: ${typeof args.config}`);
return {
nodeType: args.nodeType || 'unknown',
displayName: 'Unknown Node',
valid: false,
missingRequiredFields: [
'Invalid config format - expected object',
'🔧 RECOVERY: Use format { "resource": "...", "operation": "..." } or {} for empty config'
]
};
}
return this.validateNodeMinimal(args.nodeType, args.config);
case 'get_property_dependencies':
this.validateToolParams(name, args, ['nodeType']);
return this.getPropertyDependencies(args.nodeType, args.config);
case 'get_node_as_tool_info':
this.validateToolParams(name, args, ['nodeType']);
return this.getNodeAsToolInfo(args.nodeType);
case 'list_templates':
// No required params
const listLimit = Math.min(Math.max(Number(args.limit) || 10, 1), 100);
const listOffset = Math.max(Number(args.offset) || 0, 0);
const sortBy = args.sortBy || 'views';
const includeMetadata = Boolean(args.includeMetadata);
return this.listTemplates(listLimit, listOffset, sortBy, includeMetadata);
case 'list_node_templates':
this.validateToolParams(name, args, ['nodeTypes']);
const templateLimit = Math.min(Math.max(Number(args.limit) || 10, 1), 100);
const templateOffset = Math.max(Number(args.offset) || 0, 0);
return this.listNodeTemplates(args.nodeTypes, templateLimit, templateOffset);
case 'get_template':
this.validateToolParams(name, args, ['templateId']);
const templateId = Number(args.templateId);
const mode = args.mode || 'full';
return this.getTemplate(templateId, mode);
case 'search_templates':
this.validateToolParams(name, args, ['query']);
const searchLimit = Math.min(Math.max(Number(args.limit) || 20, 1), 100);
const searchOffset = Math.max(Number(args.offset) || 0, 0);
const searchFields = args.fields as string[] | undefined;
return this.searchTemplates(args.query, searchLimit, searchOffset, searchFields);
case 'get_templates_for_task':
this.validateToolParams(name, args, ['task']);
const taskLimit = Math.min(Math.max(Number(args.limit) || 10, 1), 100);
const taskOffset = Math.max(Number(args.offset) || 0, 0);
return this.getTemplatesForTask(args.task, taskLimit, taskOffset);
case 'search_templates_by_metadata':
// No required params - all filters are optional
const metadataLimit = Math.min(Math.max(Number(args.limit) || 20, 1), 100);
const metadataOffset = Math.max(Number(args.offset) || 0, 0);
return this.searchTemplatesByMetadata({
category: args.category,
complexity: args.complexity,
maxSetupMinutes: args.maxSetupMinutes ? Number(args.maxSetupMinutes) : undefined,
minSetupMinutes: args.minSetupMinutes ? Number(args.minSetupMinutes) : undefined,
requiredService: args.requiredService,
targetAudience: args.targetAudience
}, metadataLimit, metadataOffset);
case 'validate_workflow':
this.validateToolParams(name, args, ['workflow']);
return this.validateWorkflow(args.workflow, args.options);
case 'validate_workflow_connections':
this.validateToolParams(name, args, ['workflow']);
return this.validateWorkflowConnections(args.workflow);
case 'validate_workflow_expressions':
this.validateToolParams(name, args, ['workflow']);
return this.validateWorkflowExpressions(args.workflow);
// n8n Management Tools (if API is configured)
case 'n8n_create_workflow':
this.validateToolParams(name, args, ['name', 'nodes', 'connections']);
return n8nHandlers.handleCreateWorkflow(args, this.instanceContext);
case 'n8n_get_workflow':
this.validateToolParams(name, args, ['id']);
return n8nHandlers.handleGetWorkflow(args, this.instanceContext);
case 'n8n_get_workflow_details':
this.validateToolParams(name, args, ['id']);
return n8nHandlers.handleGetWorkflowDetails(args, this.instanceContext);
case 'n8n_get_workflow_structure':
this.validateToolParams(name, args, ['id']);
return n8nHandlers.handleGetWorkflowStructure(args, this.instanceContext);
case 'n8n_get_workflow_minimal':
this.validateToolParams(name, args, ['id']);
return n8nHandlers.handleGetWorkflowMinimal(args, this.instanceContext);
case 'n8n_update_full_workflow':
this.validateToolParams(name, args, ['id']);
return n8nHandlers.handleUpdateWorkflow(args, this.instanceContext);
case 'n8n_update_partial_workflow':
this.validateToolParams(name, args, ['id', 'operations']);
return handleUpdatePartialWorkflow(args, this.instanceContext);
case 'n8n_delete_workflow':
this.validateToolParams(name, args, ['id']);
return n8nHandlers.handleDeleteWorkflow(args, this.instanceContext);
case 'n8n_list_workflows':
// No required parameters
return n8nHandlers.handleListWorkflows(args, this.instanceContext);
case 'n8n_validate_workflow':
this.validateToolParams(name, args, ['id']);
await this.ensureInitialized();
if (!this.repository) throw new Error('Repository not initialized');
return n8nHandlers.handleValidateWorkflow(args, this.repository, this.instanceContext);
case 'n8n_autofix_workflow':
this.validateToolParams(name, args, ['id']);
await this.ensureInitialized();
if (!this.repository) throw new Error('Repository not initialized');
return n8nHandlers.handleAutofixWorkflow(args, this.repository, this.instanceContext);
case 'n8n_trigger_webhook_workflow':
this.validateToolParams(name, args, ['webhookUrl']);
return n8nHandlers.handleTriggerWebhookWorkflow(args, this.instanceContext);
case 'n8n_get_execution':
this.validateToolParams(name, args, ['id']);
return n8nHandlers.handleGetExecution(args, this.instanceContext);
case 'n8n_list_executions':
// No required parameters
return n8nHandlers.handleListExecutions(args, this.instanceContext);
case 'n8n_delete_execution':
this.validateToolParams(name, args, ['id']);
return n8nHandlers.handleDeleteExecution(args, this.instanceContext);
case 'n8n_health_check':
// No required parameters
return n8nHandlers.handleHealthCheck(this.instanceContext);
case 'n8n_list_available_tools':
// No required parameters
return n8nHandlers.handleListAvailableTools(this.instanceContext);
case 'n8n_diagnostic':
// No required parameters
return n8nHandlers.handleDiagnostic({ params: { arguments: args } }, this.instanceContext);
default:
throw new Error(`Unknown tool: ${name}`);
}
}
private async listNodes(filters: any = {}): Promise<any> {
await this.ensureInitialized();
let query = 'SELECT * FROM nodes WHERE 1=1';
const params: any[] = [];
// console.log('DEBUG list_nodes:', { filters, query, params }); // Removed to prevent stdout interference
if (filters.package) {
// Handle both formats
const packageVariants = [
filters.package,
`@n8n/${filters.package}`,
filters.package.replace('@n8n/', '')
];
query += ' AND package_name IN (' + packageVariants.map(() => '?').join(',') + ')';
params.push(...packageVariants);
}
if (filters.category) {
query += ' AND category = ?';
params.push(filters.category);
}
if (filters.developmentStyle) {
query += ' AND development_style = ?';
params.push(filters.developmentStyle);
}
if (filters.isAITool !== undefined) {
query += ' AND is_ai_tool = ?';
params.push(filters.isAITool ? 1 : 0);
}
query += ' ORDER BY display_name';
if (filters.limit) {
query += ' LIMIT ?';
params.push(filters.limit);
}
const nodes = this.db!.prepare(query).all(...params) as NodeRow[];
return {
nodes: nodes.map(node => ({
nodeType: node.node_type,
displayName: node.display_name,
description: node.description,
category: node.category,
package: node.package_name,
developmentStyle: node.development_style,
isAITool: Number(node.is_ai_tool) === 1,
isTrigger: Number(node.is_trigger) === 1,
isVersioned: Number(node.is_versioned) === 1,
})),
totalCount: nodes.length,
};
}
private async getNodeInfo(nodeType: string): Promise<any> {
await this.ensureInitialized();
if (!this.repository) throw new Error('Repository not initialized');
// First try with normalized type (repository will also normalize internally)
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
let node = this.repository.getNode(normalizedType);
if (!node && normalizedType !== nodeType) {
// Try original if normalization changed it
node = this.repository.getNode(nodeType);
}
if (!node) {
// Fallback to other alternatives for edge cases
const alternatives = getNodeTypeAlternatives(normalizedType);
for (const alt of alternatives) {
const found = this.repository!.getNode(alt);
if (found) {
node = found;
break;
}
}
}
if (!node) {
throw new Error(`Node ${nodeType} not found`);
}
// Add AI tool capabilities information with null safety
const aiToolCapabilities = {
canBeUsedAsTool: true, // Any node can be used as a tool in n8n
hasUsableAsToolProperty: node.isAITool ?? false,
requiresEnvironmentVariable: !(node.isAITool ?? false) && node.package !== 'n8n-nodes-base',
toolConnectionType: 'ai_tool',
commonToolUseCases: this.getCommonAIToolUseCases(node.nodeType),
environmentRequirement: node.package && node.package !== 'n8n-nodes-base' ?
'N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true' :
null
};
// Process outputs to provide clear mapping with null safety
let outputs = undefined;
if (node.outputNames && Array.isArray(node.outputNames) && node.outputNames.length > 0) {
outputs = node.outputNames.map((name: string, index: number) => {
// Special handling for loop nodes like SplitInBatches
const descriptions = this.getOutputDescriptions(node.nodeType, name, index);
return {
index,
name,
description: descriptions?.description ?? '',
connectionGuidance: descriptions?.connectionGuidance ?? ''
};
});
}
return {
...node,
workflowNodeType: getWorkflowNodeType(node.package ?? 'n8n-nodes-base', node.nodeType),
aiToolCapabilities,
outputs
};
}
/**
* Primary search method used by ALL MCP search tools.
*
* This method automatically detects and uses FTS5 full-text search when available
* (lines 1189-1203), falling back to LIKE queries only if FTS5 table doesn't exist.
*
* NOTE: This is separate from NodeRepository.searchNodes() which is legacy LIKE-based.
* All MCP tool invocations route through this method to leverage FTS5 performance.
*/
private async searchNodes(
query: string,
limit: number = 20,
options?: {
mode?: 'OR' | 'AND' | 'FUZZY';
includeSource?: boolean;
includeExamples?: boolean;
}
): Promise<any> {
await this.ensureInitialized();
if (!this.db) throw new Error('Database not initialized');
// Normalize the query if it looks like a full node type
let normalizedQuery = query;
// Check if query contains node type patterns and normalize them
if (query.includes('n8n-nodes-base.') || query.includes('@n8n/n8n-nodes-langchain.')) {
normalizedQuery = query
.replace(/n8n-nodes-base\./g, 'nodes-base.')
.replace(/@n8n\/n8n-nodes-langchain\./g, 'nodes-langchain.');
}
const searchMode = options?.mode || 'OR';
// Check if FTS5 table exists
const ftsExists = this.db.prepare(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='nodes_fts'
`).get();
if (ftsExists) {
// Use FTS5 search with normalized query
logger.debug(`Using FTS5 search with includeExamples=${options?.includeExamples}`);
return this.searchNodesFTS(normalizedQuery, limit, searchMode, options);
} else {
// Fallback to LIKE search with normalized query
logger.debug('Using LIKE search (no FTS5)');
return this.searchNodesLIKE(normalizedQuery, limit, options);
}
}
private async searchNodesFTS(
query: string,
limit: number,
mode: 'OR' | 'AND' | 'FUZZY',
options?: { includeSource?: boolean; includeExamples?: boolean; }
): Promise<any> {
if (!this.db) throw new Error('Database not initialized');
// Clean and prepare the query
const cleanedQuery = query.trim();
if (!cleanedQuery) {
return { query, results: [], totalCount: 0 };
}
// For FUZZY mode, use LIKE search with typo patterns
if (mode === 'FUZZY') {
return this.searchNodesFuzzy(cleanedQuery, limit);
}
let ftsQuery: string;
// Handle exact phrase searches with quotes
if (cleanedQuery.startsWith('"') && cleanedQuery.endsWith('"')) {
// Keep exact phrase as is for FTS5
ftsQuery = cleanedQuery;
} else {
// Split into words and handle based on mode
const words = cleanedQuery.split(/\s+/).filter(w => w.length > 0);
switch (mode) {
case 'AND':
// All words must be present
ftsQuery = words.join(' AND ');
break;
case 'OR':
default:
// Any word can match (default)
ftsQuery = words.join(' OR ');
break;
}
}
try {
// Use FTS5 with ranking
const nodes = this.db.prepare(`
SELECT
n.*,
rank
FROM nodes n
JOIN nodes_fts ON n.rowid = nodes_fts.rowid
WHERE nodes_fts MATCH ?
ORDER BY
rank,
CASE
WHEN n.display_name = ? THEN 0
WHEN n.display_name LIKE ? THEN 1
WHEN n.node_type LIKE ? THEN 2
ELSE 3
END,
n.display_name
LIMIT ?
`).all(ftsQuery, cleanedQuery, `%${cleanedQuery}%`, `%${cleanedQuery}%`, limit) as (NodeRow & { rank: number })[];
// Apply additional relevance scoring for better results
const scoredNodes = nodes.map(node => {
const relevanceScore = this.calculateRelevanceScore(node, cleanedQuery);
return { ...node, relevanceScore };
});
// Sort by combined score (FTS rank + relevance score)
scoredNodes.sort((a, b) => {
// Prioritize exact matches
if (a.display_name.toLowerCase() === cleanedQuery.toLowerCase()) return -1;
if (b.display_name.toLowerCase() === cleanedQuery.toLowerCase()) return 1;
// Then by relevance score
if (a.relevanceScore !== b.relevanceScore) {
return b.relevanceScore - a.relevanceScore;
}
// Then by FTS rank
return a.rank - b.rank;
});
// If FTS didn't find key primary nodes, augment with LIKE search
const hasHttpRequest = scoredNodes.some(n => n.node_type === 'nodes-base.httpRequest');
if (cleanedQuery.toLowerCase().includes('http') && !hasHttpRequest) {
// FTS missed HTTP Request, fall back to LIKE search
logger.debug('FTS missed HTTP Request node, augmenting with LIKE search');
return this.searchNodesLIKE(query, limit);
}
const result: any = {
query,
results: scoredNodes.map(node => ({
nodeType: node.node_type,
workflowNodeType: getWorkflowNodeType(node.package_name, node.node_type),
displayName: node.display_name,
description: node.description,
category: node.category,
package: node.package_name,
relevance: this.calculateRelevance(node, cleanedQuery)
})),
totalCount: scoredNodes.length
};
// Only include mode if it's not the default
if (mode !== 'OR') {
result.mode = mode;
}
// Add examples if requested
if (options && options.includeExamples) {
try {
for (const nodeResult of result.results) {
const examples = this.db!.prepare(`
SELECT
parameters_json,
template_name,
template_views
FROM template_node_configs
WHERE node_type = ?
ORDER BY rank
LIMIT 2
`).all(nodeResult.workflowNodeType) as any[];
if (examples.length > 0) {
nodeResult.examples = examples.map((ex: any) => ({
configuration: JSON.parse(ex.parameters_json),
template: ex.template_name,
views: ex.template_views
}));
}
}
} catch (error: any) {
logger.error(`Failed to add examples:`, error);
}
}
// Track search query telemetry
telemetry.trackSearchQuery(query, scoredNodes.length, mode ?? 'OR');
return result;
} catch (error: any) {
// If FTS5 query fails, fallback to LIKE search
logger.warn('FTS5 search failed, falling back to LIKE search:', error.message);
// Special handling for syntax errors
if (error.message.includes('syntax error') || error.message.includes('fts5')) {
logger.warn(`FTS5 syntax error for query "${query}" in mode ${mode}`);
// For problematic queries, use LIKE search with mode info
const likeResult = await this.searchNodesLIKE(query, limit);
// Track search query telemetry for fallback
telemetry.trackSearchQuery(query, likeResult.results?.length ?? 0, `${mode}_LIKE_FALLBACK`);
return {
...likeResult,
mode
};
}
return this.searchNodesLIKE(query, limit);
}
}
private async searchNodesFuzzy(query: string, limit: number): Promise<any> {
if (!this.db) throw new Error('Database not initialized');
// Split into words for fuzzy matching
const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 0);
if (words.length === 0) {
return { query, results: [], totalCount: 0, mode: 'FUZZY' };
}
// For fuzzy search, get ALL nodes to ensure we don't miss potential matches
// We'll limit results after scoring
const candidateNodes = this.db!.prepare(`
SELECT * FROM nodes
`).all() as NodeRow[];
// Calculate fuzzy scores for candidate nodes
const scoredNodes = candidateNodes.map(node => {
const score = this.calculateFuzzyScore(node, query);
return { node, score };
});
// Filter and sort by score
const matchingNodes = scoredNodes
.filter(item => item.score >= 200) // Lower threshold for better typo tolerance
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(item => item.node);
// Debug logging
if (matchingNodes.length === 0) {
const topScores = scoredNodes
.sort((a, b) => b.score - a.score)
.slice(0, 5);
logger.debug(`FUZZY search for "${query}" - no matches above 400. Top scores:`,
topScores.map(s => ({ name: s.node.display_name, score: s.score })));
}
return {
query,
mode: 'FUZZY',
results: matchingNodes.map(node => ({
nodeType: node.node_type,
workflowNodeType: getWorkflowNodeType(node.package_name, node.node_type),
displayName: node.display_name,
description: node.description,
category: node.category,
package: node.package_name
})),
totalCount: matchingNodes.length
};
}
private calculateFuzzyScore(node: NodeRow, query: string): number {
const queryLower = query.toLowerCase();
const displayNameLower = node.display_name.toLowerCase();
const nodeTypeLower = node.node_type.toLowerCase();
const nodeTypeClean = nodeTypeLower.replace(/^nodes-base\./, '').replace(/^nodes-langchain\./, '');
// Exact match gets highest score
if (displayNameLower === queryLower || nodeTypeClean === queryLower) {
return 1000;
}
// Calculate edit distances for different parts
const nameDistance = this.getEditDistance(queryLower, displayNameLower);
const typeDistance = this.getEditDistance(queryLower, nodeTypeClean);
// Also check individual words in the display name
const nameWords = displayNameLower.split(/\s+/);
let minWordDistance = Infinity;
for (const word of nameWords) {
const distance = this.getEditDistance(queryLower, word);
if (distance < minWordDistance) {
minWordDistance = distance;
}
}
// Calculate best match score
const bestDistance = Math.min(nameDistance, typeDistance, minWordDistance);
// Use the length of the matched word for similarity calculation
let matchedLen = queryLower.length;
if (minWordDistance === bestDistance) {
// Find which word matched best
for (const word of nameWords) {
if (this.getEditDistance(queryLower, word) === minWordDistance) {
matchedLen = Math.max(queryLower.length, word.length);
break;
}
}
} else if (typeDistance === bestDistance) {
matchedLen = Math.max(queryLower.length, nodeTypeClean.length);
} else {
matchedLen = Math.max(queryLower.length, displayNameLower.length);
}
const similarity = 1 - (bestDistance / matchedLen);
// Boost if query is a substring
if (displayNameLower.includes(queryLower) || nodeTypeClean.includes(queryLower)) {
return 800 + (similarity * 100);
}
// Check if it's a prefix match
if (displayNameLower.startsWith(queryLower) ||
nodeTypeClean.startsWith(queryLower) ||
nameWords.some(w => w.startsWith(queryLower))) {
return 700 + (similarity * 100);
}
// Allow up to 1-2 character differences for typos
if (bestDistance <= 2) {
return 500 + ((2 - bestDistance) * 100) + (similarity * 50);
}
// Allow up to 3 character differences for longer words
if (bestDistance <= 3 && queryLower.length >= 4) {
return 400 + ((3 - bestDistance) * 50) + (similarity * 50);
}
// Base score on similarity
return similarity * 300;
}
private getEditDistance(s1: string, s2: string): number {
// Simple Levenshtein distance implementation
const m = s1.length;
const n = s2.length;
const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (s1[i - 1] === s2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
}
return dp[m][n];
}
private async searchNodesLIKE(
query: string,
limit: number,
options?: { includeSource?: boolean; includeExamples?: boolean; }
): Promise<any> {
if (!this.db) throw new Error('Database not initialized');
// This is the existing LIKE-based implementation
// Handle exact phrase searches with quotes
if (query.startsWith('"') && query.endsWith('"')) {
const exactPhrase = query.slice(1, -1);
const nodes = this.db!.prepare(`
SELECT * FROM nodes
WHERE node_type LIKE ? OR display_name LIKE ? OR description LIKE ?
LIMIT ?
`).all(`%${exactPhrase}%`, `%${exactPhrase}%`, `%${exactPhrase}%`, limit * 3) as NodeRow[];
// Apply relevance ranking for exact phrase search
const rankedNodes = this.rankSearchResults(nodes, exactPhrase, limit);
const result: any = {
query,
results: rankedNodes.map(node => ({
nodeType: node.node_type,
workflowNodeType: getWorkflowNodeType(node.package_name, node.node_type),
displayName: node.display_name,
description: node.description,
category: node.category,
package: node.package_name
})),
totalCount: rankedNodes.length
};
// Add examples if requested
if (options?.includeExamples) {
for (const nodeResult of result.results) {
try {
const examples = this.db!.prepare(`
SELECT
parameters_json,
template_name,
template_views
FROM template_node_configs
WHERE node_type = ?
ORDER BY rank
LIMIT 2
`).all(nodeResult.workflowNodeType) as any[];
if (examples.length > 0) {
nodeResult.examples = examples.map((ex: any) => ({
configuration: JSON.parse(ex.parameters_json),
template: ex.template_name,
views: ex.template_views
}));
}
} catch (error: any) {
logger.warn(`Failed to fetch examples for ${nodeResult.nodeType}:`, error.message);
}
}
}
return result;
}
// Split into words for normal search
const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 0);
if (words.length === 0) {
return { query, results: [], totalCount: 0 };
}
// Build conditions for each word
const conditions = words.map(() =>
'(node_type LIKE ? OR display_name LIKE ? OR description LIKE ?)'
).join(' OR ');
const params: any[] = words.flatMap(w => [`%${w}%`, `%${w}%`, `%${w}%`]);
// Fetch more results initially to ensure we get the best matches after ranking
params.push(limit * 3);
const nodes = this.db!.prepare(`
SELECT DISTINCT * FROM nodes
WHERE ${conditions}
LIMIT ?
`).all(...params) as NodeRow[];
// Apply relevance ranking
const rankedNodes = this.rankSearchResults(nodes, query, limit);
const result: any = {
query,
results: rankedNodes.map(node => ({
nodeType: node.node_type,
workflowNodeType: getWorkflowNodeType(node.package_name, node.node_type),
displayName: node.display_name,
description: node.description,
category: node.category,
package: node.package_name
})),
totalCount: rankedNodes.length
};
// Add examples if requested
if (options?.includeExamples) {
for (const nodeResult of result.results) {
try {
const examples = this.db!.prepare(`
SELECT
parameters_json,
template_name,
template_views
FROM template_node_configs
WHERE node_type = ?
ORDER BY rank
LIMIT 2
`).all(nodeResult.workflowNodeType) as any[];
if (examples.length > 0) {
nodeResult.examples = examples.map((ex: any) => ({
configuration: JSON.parse(ex.parameters_json),
template: ex.template_name,
views: ex.template_views
}));
}
} catch (error: any) {
logger.warn(`Failed to fetch examples for ${nodeResult.nodeType}:`, error.message);
}
}
}
return result;
}
private calculateRelevance(node: NodeRow, query: string): string {
const lowerQuery = query.toLowerCase();
if (node.node_type.toLowerCase().includes(lowerQuery)) return 'high';
if (node.display_name.toLowerCase().includes(lowerQuery)) return 'high';
if (node.description?.toLowerCase().includes(lowerQuery)) return 'medium';
return 'low';
}
private calculateRelevanceScore(node: NodeRow, query: string): number {
const query_lower = query.toLowerCase();
const name_lower = node.display_name.toLowerCase();
const type_lower = node.node_type.toLowerCase();
const type_without_prefix = type_lower.replace(/^nodes-base\./, '').replace(/^nodes-langchain\./, '');
let score = 0;
// Exact match in display name (highest priority)
if (name_lower === query_lower) {
score = 1000;
}
// Exact match in node type (without prefix)
else if (type_without_prefix === query_lower) {
score = 950;
}
// Special boost for common primary nodes
else if (query_lower === 'webhook' && node.node_type === 'nodes-base.webhook') {
score = 900;
}
else if ((query_lower === 'http' || query_lower === 'http request' || query_lower === 'http call') && node.node_type === 'nodes-base.httpRequest') {
score = 900;
}
// Additional boost for multi-word queries matching primary nodes
else if (query_lower.includes('http') && query_lower.includes('call') && node.node_type === 'nodes-base.httpRequest') {
score = 890;
}
else if (query_lower.includes('http') && node.node_type === 'nodes-base.httpRequest') {
score = 850;
}
// Boost for webhook queries
else if (query_lower.includes('webhook') && node.node_type === 'nodes-base.webhook') {
score = 850;
}
// Display name starts with query
else if (name_lower.startsWith(query_lower)) {
score = 800;
}
// Word boundary match in display name
else if (new RegExp(`\\b${query_lower}\\b`, 'i').test(node.display_name)) {
score = 700;
}
// Contains in display name
else if (name_lower.includes(query_lower)) {
score = 600;
}
// Type contains query (without prefix)
else if (type_without_prefix.includes(query_lower)) {
score = 500;
}
// Contains in description
else if (node.description?.toLowerCase().includes(query_lower)) {
score = 400;
}
return score;
}
private rankSearchResults(nodes: NodeRow[], query: string, limit: number): NodeRow[] {
const query_lower = query.toLowerCase();
// Calculate relevance scores for each node
const scoredNodes = nodes.map(node => {
const name_lower = node.display_name.toLowerCase();
const type_lower = node.node_type.toLowerCase();
const type_without_prefix = type_lower.replace(/^nodes-base\./, '').replace(/^nodes-langchain\./, '');
let score = 0;
// Exact match in display name (highest priority)
if (name_lower === query_lower) {
score = 1000;
}
// Exact match in node type (without prefix)
else if (type_without_prefix === query_lower) {
score = 950;
}
// Special boost for common primary nodes
else if (query_lower === 'webhook' && node.node_type === 'nodes-base.webhook') {
score = 900;
}
else if ((query_lower === 'http' || query_lower === 'http request' || query_lower === 'http call') && node.node_type === 'nodes-base.httpRequest') {
score = 900;
}
// Boost for webhook queries
else if (query_lower.includes('webhook') && node.node_type === 'nodes-base.webhook') {
score = 850;
}
// Additional boost for http queries
else if (query_lower.includes('http') && node.node_type === 'nodes-base.httpRequest') {
score = 850;
}
// Display name starts with query
else if (name_lower.startsWith(query_lower)) {
score = 800;
}
// Word boundary match in display name
else if (new RegExp(`\\b${query_lower}\\b`, 'i').test(node.display_name)) {
score = 700;
}
// Contains in display name
else if (name_lower.includes(query_lower)) {
score = 600;
}
// Type contains query (without prefix)
else if (type_without_prefix.includes(query_lower)) {
score = 500;
}
// Contains in description
else if (node.description?.toLowerCase().includes(query_lower)) {
score = 400;
}
// For multi-word queries, check if all words are present
const words = query_lower.split(/\s+/).filter(w => w.length > 0);
if (words.length > 1) {
const allWordsInName = words.every(word => name_lower.includes(word));
const allWordsInDesc = words.every(word => node.description?.toLowerCase().includes(word));
if (allWordsInName) score += 200;
else if (allWordsInDesc) score += 100;
// Special handling for common multi-word queries
if (query_lower === 'http call' && name_lower === 'http request') {
score = 920; // Boost HTTP Request for "http call" query
}
}
return { node, score };
});
// Sort by score (descending) and then by display name (ascending)
scoredNodes.sort((a, b) => {
if (a.score !== b.score) {
return b.score - a.score;
}
return a.node.display_name.localeCompare(b.node.display_name);
});
// Return only the requested number of results
return scoredNodes.slice(0, limit).map(item => item.node);
}
private async listAITools(): Promise<any> {
await this.ensureInitialized();
if (!this.repository) throw new Error('Repository not initialized');
const tools = this.repository.getAITools();
// Debug: Check if is_ai_tool column is populated
const aiCount = this.db!.prepare('SELECT COUNT(*) as ai_count FROM nodes WHERE is_ai_tool = 1').get() as any;
// console.log('DEBUG list_ai_tools:', {
// toolsLength: tools.length,
// aiCountInDB: aiCount.ai_count,
// sampleTools: tools.slice(0, 3)
// }); // Removed to prevent stdout interference
return {
tools,
totalCount: tools.length,
requirements: {
environmentVariable: 'N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true',
nodeProperty: 'usableAsTool: true',
},
usage: {
description: 'These nodes have the usableAsTool property set to true, making them optimized for AI agent usage.',
note: 'ANY node in n8n can be used as an AI tool by connecting it to the ai_tool port of an AI Agent node.',
examples: [
'Regular nodes like Slack, Google Sheets, or HTTP Request can be used as tools',
'Connect any node to an AI Agent\'s tool port to make it available for AI-driven automation',
'Community nodes require the environment variable to be set'
]
}
};
}
private async getNodeDocumentation(nodeType: string): Promise<any> {
await this.ensureInitialized();
if (!this.db) throw new Error('Database not initialized');
// First try with normalized type
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
let node = this.db!.prepare(`
SELECT node_type, display_name, documentation, description
FROM nodes
WHERE node_type = ?
`).get(normalizedType) as NodeRow | undefined;
// If not found and normalization changed the type, try original
if (!node && normalizedType !== nodeType) {
node = this.db!.prepare(`
SELECT node_type, display_name, documentation, description
FROM nodes
WHERE node_type = ?
`).get(nodeType) as NodeRow | undefined;
}
// If still not found, try alternatives
if (!node) {
const alternatives = getNodeTypeAlternatives(normalizedType);
for (const alt of alternatives) {
node = this.db!.prepare(`
SELECT node_type, display_name, documentation, description
FROM nodes
WHERE node_type = ?
`).get(alt) as NodeRow | undefined;
if (node) break;
}
}
if (!node) {
throw new Error(`Node ${nodeType} not found`);
}
// If no documentation, generate fallback with null safety
if (!node.documentation) {
const essentials = await this.getNodeEssentials(nodeType);
return {
nodeType: node.node_type,
displayName: node.display_name || 'Unknown Node',
documentation: `
# ${node.display_name || 'Unknown Node'}
${node.description || 'No description available.'}
## Common Properties
${essentials?.commonProperties?.length > 0 ?
essentials.commonProperties.map((p: any) =>
`### ${p.displayName || 'Property'}\n${p.description || `Type: ${p.type || 'unknown'}`}`
).join('\n\n') :
'No common properties available.'}
## Note
Full documentation is being prepared. For now, use get_node_essentials for configuration help.
`,
hasDocumentation: false
};
}
return {
nodeType: node.node_type,
displayName: node.display_name || 'Unknown Node',
documentation: node.documentation,
hasDocumentation: true,
};
}
private async getDatabaseStatistics(): Promise<any> {
await this.ensureInitialized();
if (!this.db) throw new Error('Database not initialized');
const stats = this.db!.prepare(`
SELECT
COUNT(*) as total,
SUM(is_ai_tool) as ai_tools,
SUM(is_trigger) as triggers,
SUM(is_versioned) as versioned,
SUM(CASE WHEN documentation IS NOT NULL THEN 1 ELSE 0 END) as with_docs,
COUNT(DISTINCT package_name) as packages,
COUNT(DISTINCT category) as categories
FROM nodes
`).get() as any;
const packages = this.db!.prepare(`
SELECT package_name, COUNT(*) as count
FROM nodes
GROUP BY package_name
`).all() as any[];
// Get template statistics
const templateStats = this.db!.prepare(`
SELECT
COUNT(*) as total_templates,
AVG(views) as avg_views,
MIN(views) as min_views,
MAX(views) as max_views
FROM templates
`).get() as any;
return {
totalNodes: stats.total,
totalTemplates: templateStats.total_templates || 0,
statistics: {
aiTools: stats.ai_tools,
triggers: stats.triggers,
versionedNodes: stats.versioned,
nodesWithDocumentation: stats.with_docs,
documentationCoverage: Math.round((stats.with_docs / stats.total) * 100) + '%',
uniquePackages: stats.packages,
uniqueCategories: stats.categories,
templates: {
total: templateStats.total_templates || 0,
avgViews: Math.round(templateStats.avg_views || 0),
minViews: templateStats.min_views || 0,
maxViews: templateStats.max_views || 0
}
},
packageBreakdown: packages.map(pkg => ({
package: pkg.package_name,
nodeCount: pkg.count,
})),
};
}
private async getNodeEssentials(nodeType: string, includeExamples?: boolean): Promise<any> {
await this.ensureInitialized();
if (!this.repository) throw new Error('Repository not initialized');
// Check cache first (cache key includes includeExamples)
const cacheKey = `essentials:${nodeType}:${includeExamples ? 'withExamples' : 'basic'}`;
const cached = this.cache.get(cacheKey);
if (cached) return cached;
// Get the full node information
// First try with normalized type
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
let node = this.repository.getNode(normalizedType);
if (!node && normalizedType !== nodeType) {
// Try original if normalization changed it
node = this.repository.getNode(nodeType);
}
if (!node) {
// Fallback to other alternatives for edge cases
const alternatives = getNodeTypeAlternatives(normalizedType);
for (const alt of alternatives) {
const found = this.repository!.getNode(alt);
if (found) {
node = found;
break;
}
}
}
if (!node) {
throw new Error(`Node ${nodeType} not found`);
}
// Get properties (already parsed by repository)
const allProperties = node.properties || [];
// Get essential properties
const essentials = PropertyFilter.getEssentials(allProperties, node.nodeType);
// Get operations (already parsed by repository)
const operations = node.operations || [];
const result = {
nodeType: node.nodeType,
workflowNodeType: getWorkflowNodeType(node.package ?? 'n8n-nodes-base', node.nodeType),
displayName: node.displayName,
description: node.description,
category: node.category,
version: node.version ?? '1',
isVersioned: node.isVersioned ?? false,
requiredProperties: essentials.required,
commonProperties: essentials.common,
operations: operations.map((op: any) => ({
name: op.name || op.operation,
description: op.description,
action: op.action,
resource: op.resource
})),
// Examples removed - use validate_node_operation for working configurations
metadata: {
totalProperties: allProperties.length,
isAITool: node.isAITool ?? false,
isTrigger: node.isTrigger ?? false,
isWebhook: node.isWebhook ?? false,
hasCredentials: node.credentials ? true : false,
package: node.package ?? 'n8n-nodes-base',
developmentStyle: node.developmentStyle ?? 'programmatic'
}
};
// Add examples from templates if requested
if (includeExamples) {
try {
// Use the already-computed workflowNodeType from result (line 1888)
// This ensures consistency with search_nodes behavior (line 1203)
const examples = this.db!.prepare(`
SELECT
parameters_json,
template_name,
template_views,
complexity,
use_cases,
has_credentials,
has_expressions
FROM template_node_configs
WHERE node_type = ?
ORDER BY rank
LIMIT 3
`).all(result.workflowNodeType) as any[];
if (examples.length > 0) {
(result as any).examples = examples.map((ex: any) => ({
configuration: JSON.parse(ex.parameters_json),
source: {
template: ex.template_name,
views: ex.template_views,
complexity: ex.complexity
},
useCases: ex.use_cases ? JSON.parse(ex.use_cases).slice(0, 2) : [],
metadata: {
hasCredentials: ex.has_credentials === 1,
hasExpressions: ex.has_expressions === 1
}
}));
(result as any).examplesCount = examples.length;
} else {
(result as any).examples = [];
(result as any).examplesCount = 0;
}
} catch (error: any) {
logger.warn(`Failed to fetch examples for ${nodeType}:`, error.message);
(result as any).examples = [];
(result as any).examplesCount = 0;
}
}
// Cache for 1 hour
this.cache.set(cacheKey, result, 3600);
return result;
}
private async searchNodeProperties(nodeType: string, query: string, maxResults: number = 20): Promise<any> {
await this.ensureInitialized();
if (!this.repository) throw new Error('Repository not initialized');
// Get the node
// First try with normalized type
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
let node = this.repository.getNode(normalizedType);
if (!node && normalizedType !== nodeType) {
// Try original if normalization changed it
node = this.repository.getNode(nodeType);
}
if (!node) {
// Fallback to other alternatives for edge cases
const alternatives = getNodeTypeAlternatives(normalizedType);
for (const alt of alternatives) {
const found = this.repository!.getNode(alt);
if (found) {
node = found;
break;
}
}
}
if (!node) {
throw new Error(`Node ${nodeType} not found`);
}
// Get properties and search (already parsed by repository)
const allProperties = node.properties || [];
const matches = PropertyFilter.searchProperties(allProperties, query, maxResults);
return {
nodeType: node.nodeType,
query,
matches: matches.map((match: any) => ({
name: match.name,
displayName: match.displayName,
type: match.type,
description: match.description,
path: match.path || match.name,
required: match.required,
default: match.default,
options: match.options,
showWhen: match.showWhen
})),
totalMatches: matches.length,
searchedIn: allProperties.length + ' properties'
};
}
private getPropertyValue(config: any, path: string): any {
const parts = path.split('.');
let value = config;
for (const part of parts) {
// Handle array notation like parameters[0]
const arrayMatch = part.match(/^(\w+)\[(\d+)\]$/);
if (arrayMatch) {
value = value?.[arrayMatch[1]]?.[parseInt(arrayMatch[2])];
} else {
value = value?.[part];
}
}
return value;
}
private async listTasks(category?: string): Promise<any> {
if (category) {
const categories = TaskTemplates.getTaskCategories();
const tasks = categories[category];
if (!tasks) {
throw new Error(
`Unknown category: ${category}. Available categories: ${Object.keys(categories).join(', ')}`
);
}
return {
category,
tasks: tasks.map(task => {
const template = TaskTemplates.getTaskTemplate(task);
return {
task,
description: template?.description || '',
nodeType: template?.nodeType || ''
};
})
};
}
// Return all tasks grouped by category
const categories = TaskTemplates.getTaskCategories();
const result: any = {
totalTasks: TaskTemplates.getAllTasks().length,
categories: {}
};
for (const [cat, tasks] of Object.entries(categories)) {
result.categories[cat] = tasks.map(task => {
const template = TaskTemplates.getTaskTemplate(task);
return {
task,
description: template?.description || '',
nodeType: template?.nodeType || ''
};
});
}
return result;
}
private async validateNodeConfig(
nodeType: string,
config: Record<string, any>,
mode: ValidationMode = 'operation',
profile: ValidationProfile = 'ai-friendly'
): Promise<any> {
await this.ensureInitialized();
if (!this.repository) throw new Error('Repository not initialized');
// Get node info to access properties
// First try with normalized type
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
let node = this.repository.getNode(normalizedType);
if (!node && normalizedType !== nodeType) {
// Try original if normalization changed it
node = this.repository.getNode(nodeType);
}
if (!node) {
// Fallback to other alternatives for edge cases
const alternatives = getNodeTypeAlternatives(normalizedType);
for (const alt of alternatives) {
const found = this.repository!.getNode(alt);
if (found) {
node = found;
break;
}
}
}
if (!node) {
throw new Error(`Node ${nodeType} not found`);
}
// Get properties
const properties = node.properties || [];
// Use enhanced validator with operation mode by default
const validationResult = EnhancedConfigValidator.validateWithMode(
node.nodeType,
config,
properties,
mode,
profile
);
// Add node context to result
return {
nodeType: node.nodeType,
workflowNodeType: getWorkflowNodeType(node.package, node.nodeType),
displayName: node.displayName,
...validationResult,
summary: {
hasErrors: !validationResult.valid,
errorCount: validationResult.errors.length,
warningCount: validationResult.warnings.length,
suggestionCount: validationResult.suggestions.length
}
};
}
private async getPropertyDependencies(nodeType: string, config?: Record<string, any>): Promise<any> {
await this.ensureInitialized();
if (!this.repository) throw new Error('Repository not initialized');
// Get node info to access properties
// First try with normalized type
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
let node = this.repository.getNode(normalizedType);
if (!node && normalizedType !== nodeType) {
// Try original if normalization changed it
node = this.repository.getNode(nodeType);
}
if (!node) {
// Fallback to other alternatives for edge cases
const alternatives = getNodeTypeAlternatives(normalizedType);
for (const alt of alternatives) {
const found = this.repository!.getNode(alt);
if (found) {
node = found;
break;
}
}
}
if (!node) {
throw new Error(`Node ${nodeType} not found`);
}
// Get properties
const properties = node.properties || [];
// Analyze dependencies
const analysis = PropertyDependencies.analyze(properties);
// If config provided, check visibility impact
let visibilityImpact = null;
if (config) {
visibilityImpact = PropertyDependencies.getVisibilityImpact(properties, config);
}
return {
nodeType: node.nodeType,
displayName: node.displayName,
...analysis,
currentConfig: config ? {
providedValues: config,
visibilityImpact
} : undefined
};
}
private async getNodeAsToolInfo(nodeType: string): Promise<any> {
await this.ensureInitialized();
if (!this.repository) throw new Error('Repository not initialized');
// Get node info
// First try with normalized type
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
let node = this.repository.getNode(normalizedType);
if (!node && normalizedType !== nodeType) {
// Try original if normalization changed it
node = this.repository.getNode(nodeType);
}
if (!node) {
// Fallback to other alternatives for edge cases
const alternatives = getNodeTypeAlternatives(normalizedType);
for (const alt of alternatives) {
const found = this.repository!.getNode(alt);
if (found) {
node = found;
break;
}
}
}
if (!node) {
throw new Error(`Node ${nodeType} not found`);
}
// Determine common AI tool use cases based on node type
const commonUseCases = this.getCommonAIToolUseCases(node.nodeType);
// Build AI tool capabilities info
const aiToolCapabilities = {
canBeUsedAsTool: true, // In n8n, ANY node can be used as a tool when connected to AI Agent
hasUsableAsToolProperty: node.isAITool,
requiresEnvironmentVariable: !node.isAITool && node.package !== 'n8n-nodes-base',
connectionType: 'ai_tool',
commonUseCases,
requirements: {
connection: 'Connect to the "ai_tool" port of an AI Agent node',
environment: node.package !== 'n8n-nodes-base' ?
'Set N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true for community nodes' :
'No special environment variables needed for built-in nodes'
},
examples: this.getAIToolExamples(node.nodeType),
tips: [
'Give the tool a clear, descriptive name in the AI Agent settings',
'Write a detailed tool description to help the AI understand when to use it',
'Test the node independently before connecting it as a tool',
node.isAITool ?
'This node is optimized for AI tool usage' :
'This is a regular node that can be used as an AI tool'
]
};
return {
nodeType: node.nodeType,
workflowNodeType: getWorkflowNodeType(node.package, node.nodeType),
displayName: node.displayName,
description: node.description,
package: node.package,
isMarkedAsAITool: node.isAITool,
aiToolCapabilities
};
}
private getOutputDescriptions(nodeType: string, outputName: string, index: number): { description: string, connectionGuidance: string } {
// Special handling for loop nodes
if (nodeType === 'nodes-base.splitInBatches') {
if (outputName === 'done' && index === 0) {
return {
description: 'Final processed data after all iterations complete',
connectionGuidance: 'Connect to nodes that should run AFTER the loop completes'
};
} else if (outputName === 'loop' && index === 1) {
return {
description: 'Current batch data for this iteration',
connectionGuidance: 'Connect to nodes that process items INSIDE the loop (and connect their output back to this node)'
};
}
}
// Special handling for IF node
if (nodeType === 'nodes-base.if') {
if (outputName === 'true' && index === 0) {
return {
description: 'Items that match the condition',
connectionGuidance: 'Connect to nodes that handle the TRUE case'
};
} else if (outputName === 'false' && index === 1) {
return {
description: 'Items that do not match the condition',
connectionGuidance: 'Connect to nodes that handle the FALSE case'
};
}
}
// Special handling for Switch node
if (nodeType === 'nodes-base.switch') {
return {
description: `Output ${index}: ${outputName || 'Route ' + index}`,
connectionGuidance: `Connect to nodes for the "${outputName || 'route ' + index}" case`
};
}
// Default handling
return {
description: outputName || `Output ${index}`,
connectionGuidance: `Connect to downstream nodes`
};
}
private getCommonAIToolUseCases(nodeType: string): string[] {
const useCaseMap: Record<string, string[]> = {
'nodes-base.slack': [
'Send notifications about task completion',
'Post updates to channels',
'Send direct messages',
'Create alerts and reminders'
],
'nodes-base.googleSheets': [
'Read data for analysis',
'Log results and outputs',
'Update spreadsheet records',
'Create reports'
],
'nodes-base.gmail': [
'Send email notifications',
'Read and process emails',
'Send reports and summaries',
'Handle email-based workflows'
],
'nodes-base.httpRequest': [
'Call external APIs',
'Fetch data from web services',
'Send webhooks',
'Integrate with any REST API'
],
'nodes-base.postgres': [
'Query database for information',
'Store analysis results',
'Update records based on AI decisions',
'Generate reports from data'
],
'nodes-base.webhook': [
'Receive external triggers',
'Create callback endpoints',
'Handle incoming data',
'Integrate with external systems'
]
};
// Check for partial matches
for (const [key, useCases] of Object.entries(useCaseMap)) {
if (nodeType.includes(key)) {
return useCases;
}
}
// Generic use cases for unknown nodes
return [
'Perform automated actions',
'Integrate with external services',
'Process and transform data',
'Extend AI agent capabilities'
];
}
private getAIToolExamples(nodeType: string): any {
const exampleMap: Record<string, any> = {
'nodes-base.slack': {
toolName: 'Send Slack Message',
toolDescription: 'Sends a message to a specified Slack channel or user. Use this to notify team members about important events or results.',
nodeConfig: {
resource: 'message',
operation: 'post',
channel: '={{ $fromAI("channel", "The Slack channel to send to, e.g. #general") }}',
text: '={{ $fromAI("message", "The message content to send") }}'
}
},
'nodes-base.googleSheets': {
toolName: 'Update Google Sheet',
toolDescription: 'Reads or updates data in a Google Sheets spreadsheet. Use this to log information, retrieve data, or update records.',
nodeConfig: {
operation: 'append',
sheetId: 'your-sheet-id',
range: 'A:Z',
dataMode: 'autoMap'
}
},
'nodes-base.httpRequest': {
toolName: 'Call API',
toolDescription: 'Makes HTTP requests to external APIs. Use this to fetch data, trigger webhooks, or integrate with any web service.',
nodeConfig: {
method: '={{ $fromAI("method", "HTTP method: GET, POST, PUT, DELETE") }}',
url: '={{ $fromAI("url", "The complete API endpoint URL") }}',
sendBody: true,
bodyContentType: 'json',
jsonBody: '={{ $fromAI("body", "Request body as JSON object") }}'
}
}
};
// Check for exact match or partial match
for (const [key, example] of Object.entries(exampleMap)) {
if (nodeType.includes(key)) {
return example;
}
}
// Generic example
return {
toolName: 'Custom Tool',
toolDescription: 'Performs specific operations. Describe what this tool does and when to use it.',
nodeConfig: {
note: 'Configure the node based on its specific requirements'
}
};
}
private async validateNodeMinimal(nodeType: string, config: Record<string, any>): Promise<any> {
await this.ensureInitialized();
if (!this.repository) throw new Error('Repository not initialized');
// Get node info
// First try with normalized type
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
let node = this.repository.getNode(normalizedType);
if (!node && normalizedType !== nodeType) {
// Try original if normalization changed it
node = this.repository.getNode(nodeType);
}
if (!node) {
// Fallback to other alternatives for edge cases
const alternatives = getNodeTypeAlternatives(normalizedType);
for (const alt of alternatives) {
const found = this.repository!.getNode(alt);
if (found) {
node = found;
break;
}
}
}
if (!node) {
throw new Error(`Node ${nodeType} not found`);
}
// Get properties
const properties = node.properties || [];
// Extract operation context (safely handle undefined config properties)
const operationContext = {
resource: config?.resource,
operation: config?.operation,
action: config?.action,
mode: config?.mode
};
// Find missing required fields
const missingFields: string[] = [];
for (const prop of properties) {
// Skip if not required
if (!prop.required) continue;
// Skip if not visible based on current config
if (prop.displayOptions) {
let isVisible = true;
// Check show conditions
if (prop.displayOptions.show) {
for (const [key, values] of Object.entries(prop.displayOptions.show)) {
const configValue = config?.[key];
const expectedValues = Array.isArray(values) ? values : [values];
if (!expectedValues.includes(configValue)) {
isVisible = false;
break;
}
}
}
// Check hide conditions
if (isVisible && prop.displayOptions.hide) {
for (const [key, values] of Object.entries(prop.displayOptions.hide)) {
const configValue = config?.[key];
const expectedValues = Array.isArray(values) ? values : [values];
if (expectedValues.includes(configValue)) {
isVisible = false;
break;
}
}
}
if (!isVisible) continue;
}
// Check if field is missing (safely handle null/undefined config)
if (!config || !(prop.name in config)) {
missingFields.push(prop.displayName || prop.name);
}
}
return {
nodeType: node.nodeType,
displayName: node.displayName,
valid: missingFields.length === 0,
missingRequiredFields: missingFields
};
}
// Method removed - replaced by getToolsDocumentation
private async getToolsDocumentation(topic?: string, depth: 'essentials' | 'full' = 'essentials'): Promise<string> {
if (!topic || topic === 'overview') {
return getToolsOverview(depth);
}
return getToolDocumentation(topic, depth);
}
// Add connect method to accept any transport
async connect(transport: any): Promise<void> {
await this.ensureInitialized();
await this.server.connect(transport);
logger.info('MCP Server connected', {
transportType: transport.constructor.name
});
}
// Template-related methods
private async listTemplates(limit: number = 10, offset: number = 0, sortBy: 'views' | 'created_at' | 'name' = 'views', includeMetadata: boolean = false): Promise<any> {
await this.ensureInitialized();
if (!this.templateService) throw new Error('Template service not initialized');
const result = await this.templateService.listTemplates(limit, offset, sortBy, includeMetadata);
return {
...result,
tip: result.items.length > 0 ?
`Use get_template(templateId) to get full workflow details. Total: ${result.total} templates available.` :
"No templates found. Run 'npm run fetch:templates' to update template database"
};
}
private async listNodeTemplates(nodeTypes: string[], limit: number = 10, offset: number = 0): Promise<any> {
await this.ensureInitialized();
if (!this.templateService) throw new Error('Template service not initialized');
const result = await this.templateService.listNodeTemplates(nodeTypes, limit, offset);
if (result.items.length === 0 && offset === 0) {
return {
...result,
message: `No templates found using nodes: ${nodeTypes.join(', ')}`,
tip: "Try searching with more common nodes or run 'npm run fetch:templates' to update template database"
};
}
return {
...result,
tip: `Showing ${result.items.length} of ${result.total} templates. Use offset for pagination.`
};
}
private async getTemplate(templateId: number, mode: 'nodes_only' | 'structure' | 'full' = 'full'): Promise<any> {
await this.ensureInitialized();
if (!this.templateService) throw new Error('Template service not initialized');
const template = await this.templateService.getTemplate(templateId, mode);
if (!template) {
return {
error: `Template ${templateId} not found`,
tip: "Use list_templates, list_node_templates or search_templates to find available templates"
};
}
const usage = mode === 'nodes_only' ? "Node list for quick overview" :
mode === 'structure' ? "Workflow structure without full details" :
"Complete workflow JSON ready to import into n8n";
return {
mode,
template,
usage
};
}
private async searchTemplates(query: string, limit: number = 20, offset: number = 0, fields?: string[]): Promise<any> {
await this.ensureInitialized();
if (!this.templateService) throw new Error('Template service not initialized');
const result = await this.templateService.searchTemplates(query, limit, offset, fields);
if (result.items.length === 0 && offset === 0) {
return {
...result,
message: `No templates found matching: "${query}"`,
tip: "Try different keywords or run 'npm run fetch:templates' to update template database"
};
}
return {
...result,
query,
tip: `Found ${result.total} templates matching "${query}". Showing ${result.items.length}.`
};
}
private async getTemplatesForTask(task: string, limit: number = 10, offset: number = 0): Promise<any> {
await this.ensureInitialized();
if (!this.templateService) throw new Error('Template service not initialized');
const result = await this.templateService.getTemplatesForTask(task, limit, offset);
const availableTasks = this.templateService.listAvailableTasks();
if (result.items.length === 0 && offset === 0) {
return {
...result,
message: `No templates found for task: ${task}`,
availableTasks,
tip: "Try a different task or use search_templates for custom searches"
};
}
return {
...result,
task,
description: this.getTaskDescription(task),
tip: `${result.total} templates available for ${task}. Showing ${result.items.length}.`
};
}
private async searchTemplatesByMetadata(filters: {
category?: string;
complexity?: 'simple' | 'medium' | 'complex';
maxSetupMinutes?: number;
minSetupMinutes?: number;
requiredService?: string;
targetAudience?: string;
}, limit: number = 20, offset: number = 0): Promise<any> {
await this.ensureInitialized();
if (!this.templateService) throw new Error('Template service not initialized');
const result = await this.templateService.searchTemplatesByMetadata(filters, limit, offset);
// Build filter summary for feedback
const filterSummary: string[] = [];
if (filters.category) filterSummary.push(`category: ${filters.category}`);
if (filters.complexity) filterSummary.push(`complexity: ${filters.complexity}`);
if (filters.maxSetupMinutes) filterSummary.push(`max setup: ${filters.maxSetupMinutes} min`);
if (filters.minSetupMinutes) filterSummary.push(`min setup: ${filters.minSetupMinutes} min`);
if (filters.requiredService) filterSummary.push(`service: ${filters.requiredService}`);
if (filters.targetAudience) filterSummary.push(`audience: ${filters.targetAudience}`);
if (result.items.length === 0 && offset === 0) {
// Get available categories and audiences for suggestions
const availableCategories = await this.templateService.getAvailableCategories();
const availableAudiences = await this.templateService.getAvailableTargetAudiences();
return {
...result,
message: `No templates found with filters: ${filterSummary.join(', ')}`,
availableCategories: availableCategories.slice(0, 10),
availableAudiences: availableAudiences.slice(0, 5),
tip: "Try broader filters or different categories. Use list_templates to see all templates."
};
}
return {
...result,
filters,
filterSummary: filterSummary.join(', '),
tip: `Found ${result.total} templates matching filters. Showing ${result.items.length}. Each includes AI-generated metadata.`
};
}
private getTaskDescription(task: string): string {
const descriptions: Record<string, string> = {
'ai_automation': 'AI-powered workflows using OpenAI, LangChain, and other AI tools',
'data_sync': 'Synchronize data between databases, spreadsheets, and APIs',
'webhook_processing': 'Process incoming webhooks and trigger automated actions',
'email_automation': 'Send, receive, and process emails automatically',
'slack_integration': 'Integrate with Slack for notifications and bot interactions',
'data_transformation': 'Transform, clean, and manipulate data',
'file_processing': 'Handle file uploads, downloads, and transformations',
'scheduling': 'Schedule recurring tasks and time-based automations',
'api_integration': 'Connect to external APIs and web services',
'database_operations': 'Query, insert, update, and manage database records'
};
return descriptions[task] || 'Workflow templates for this task';
}
private async validateWorkflow(workflow: any, options?: any): Promise<any> {
await this.ensureInitialized();
if (!this.repository) throw new Error('Repository not initialized');
// Enhanced logging for workflow validation
logger.info('Workflow validation requested', {
hasWorkflow: !!workflow,
workflowType: typeof workflow,
hasNodes: workflow?.nodes !== undefined,
nodesType: workflow?.nodes ? typeof workflow.nodes : 'undefined',
nodesIsArray: Array.isArray(workflow?.nodes),
nodesCount: Array.isArray(workflow?.nodes) ? workflow.nodes.length : 0,
hasConnections: workflow?.connections !== undefined,
connectionsType: workflow?.connections ? typeof workflow.connections : 'undefined',
options: options
});
// Help n8n AI agents with common mistakes
if (!workflow || typeof workflow !== 'object') {
return {
valid: false,
errors: [{
node: 'workflow',
message: 'Workflow must be an object with nodes and connections',
details: 'Expected format: ' + getWorkflowExampleString()
}],
summary: { errorCount: 1 }
};
}
if (!workflow.nodes || !Array.isArray(workflow.nodes)) {
return {
valid: false,
errors: [{
node: 'workflow',
message: 'Workflow must have a nodes array',
details: 'Expected: workflow.nodes = [array of node objects]. ' + getWorkflowExampleString()
}],
summary: { errorCount: 1 }
};
}
if (!workflow.connections || typeof workflow.connections !== 'object') {
return {
valid: false,
errors: [{
node: 'workflow',
message: 'Workflow must have a connections object',
details: 'Expected: workflow.connections = {} (can be empty object). ' + getWorkflowExampleString()
}],
summary: { errorCount: 1 }
};
}
// Create workflow validator instance
const validator = new WorkflowValidator(
this.repository,
EnhancedConfigValidator
);
try {
const result = await validator.validateWorkflow(workflow, options);
// Format the response for better readability
const response: any = {
valid: result.valid,
summary: {
totalNodes: result.statistics.totalNodes,
enabledNodes: result.statistics.enabledNodes,
triggerNodes: result.statistics.triggerNodes,
validConnections: result.statistics.validConnections,
invalidConnections: result.statistics.invalidConnections,
expressionsValidated: result.statistics.expressionsValidated,
errorCount: result.errors.length,
warningCount: result.warnings.length
},
// Always include errors and warnings arrays for consistent API response
errors: result.errors.map(e => ({
node: e.nodeName || 'workflow',
message: e.message,
details: e.details
})),
warnings: result.warnings.map(w => ({
node: w.nodeName || 'workflow',
message: w.message,
details: w.details
}))
};
if (result.suggestions.length > 0) {
response.suggestions = result.suggestions;
}
// Track validation details in telemetry
if (!result.valid && result.errors.length > 0) {
// Track each validation error for analysis
result.errors.forEach(error => {
telemetry.trackValidationDetails(
error.nodeName || 'workflow',
error.type || 'validation_error',
{
message: error.message,
nodeCount: workflow.nodes?.length ?? 0,
hasConnections: Object.keys(workflow.connections || {}).length > 0
}
);
});
}
// Track successfully validated workflows in telemetry
if (result.valid) {
telemetry.trackWorkflowCreation(workflow, true);
}
return response;
} catch (error) {
logger.error('Error validating workflow:', error);
return {
valid: false,
error: error instanceof Error ? error.message : 'Unknown error validating workflow',
tip: 'Ensure the workflow JSON includes nodes array and connections object'
};
}
}
private async validateWorkflowConnections(workflow: any): Promise<any> {
await this.ensureInitialized();
if (!this.repository) throw new Error('Repository not initialized');
// Create workflow validator instance
const validator = new WorkflowValidator(
this.repository,
EnhancedConfigValidator
);
try {
// Validate only connections
const result = await validator.validateWorkflow(workflow, {
validateNodes: false,
validateConnections: true,
validateExpressions: false
});
const response: any = {
valid: result.errors.length === 0,
statistics: {
totalNodes: result.statistics.totalNodes,
triggerNodes: result.statistics.triggerNodes,
validConnections: result.statistics.validConnections,
invalidConnections: result.statistics.invalidConnections
}
};
// Filter to only connection-related issues
const connectionErrors = result.errors.filter(e =>
e.message.includes('connection') ||
e.message.includes('cycle') ||
e.message.includes('orphaned')
);
const connectionWarnings = result.warnings.filter(w =>
w.message.includes('connection') ||
w.message.includes('orphaned') ||
w.message.includes('trigger')
);
if (connectionErrors.length > 0) {
response.errors = connectionErrors.map(e => ({
node: e.nodeName || 'workflow',
message: e.message
}));
}
if (connectionWarnings.length > 0) {
response.warnings = connectionWarnings.map(w => ({
node: w.nodeName || 'workflow',
message: w.message
}));
}
return response;
} catch (error) {
logger.error('Error validating workflow connections:', error);
return {
valid: false,
error: error instanceof Error ? error.message : 'Unknown error validating connections'
};
}
}
private async validateWorkflowExpressions(workflow: any): Promise<any> {
await this.ensureInitialized();
if (!this.repository) throw new Error('Repository not initialized');
// Create workflow validator instance
const validator = new WorkflowValidator(
this.repository,
EnhancedConfigValidator
);
try {
// Validate only expressions
const result = await validator.validateWorkflow(workflow, {
validateNodes: false,
validateConnections: false,
validateExpressions: true
});
const response: any = {
valid: result.errors.length === 0,
statistics: {
totalNodes: result.statistics.totalNodes,
expressionsValidated: result.statistics.expressionsValidated
}
};
// Filter to only expression-related issues
const expressionErrors = result.errors.filter(e =>
e.message.includes('Expression') ||
e.message.includes('$') ||
e.message.includes('{{')
);
const expressionWarnings = result.warnings.filter(w =>
w.message.includes('Expression') ||
w.message.includes('$') ||
w.message.includes('{{')
);
if (expressionErrors.length > 0) {
response.errors = expressionErrors.map(e => ({
node: e.nodeName || 'workflow',
message: e.message
}));
}
if (expressionWarnings.length > 0) {
response.warnings = expressionWarnings.map(w => ({
node: w.nodeName || 'workflow',
message: w.message
}));
}
// Add tips for common expression issues
if (expressionErrors.length > 0 || expressionWarnings.length > 0) {
response.tips = [
'Use {{ }} to wrap expressions',
'Reference data with $json.propertyName',
'Reference other nodes with $node["Node Name"].json',
'Use $input.item for input data in loops'
];
}
return response;
} catch (error) {
logger.error('Error validating workflow expressions:', error);
return {
valid: false,
error: error instanceof Error ? error.message : 'Unknown error validating expressions'
};
}
}
async run(): Promise<void> {
// Ensure database is initialized before starting server
await this.ensureInitialized();
const transport = new StdioServerTransport();
await this.server.connect(transport);
// Force flush stdout for Docker environments
// Docker uses block buffering which can delay MCP responses
if (!process.stdout.isTTY || process.env.IS_DOCKER) {
// Override write to auto-flush
const originalWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = function(chunk: any, encoding?: any, callback?: any) {
const result = originalWrite(chunk, encoding, callback);
// Force immediate flush
process.stdout.emit('drain');
return result;
};
}
logger.info('n8n Documentation MCP Server running on stdio transport');
// Keep the process alive and listening
process.stdin.resume();
}
async shutdown(): Promise<void> {
logger.info('Shutting down MCP server...');
// Clean up cache timers to prevent memory leaks
if (this.cache) {
try {
this.cache.destroy();
logger.info('Cache timers cleaned up');
} catch (error) {
logger.error('Error cleaning up cache:', error);
}
}
// Close database connection if it exists
if (this.db) {
try {
await this.db.close();
logger.info('Database connection closed');
} catch (error) {
logger.error('Error closing database:', error);
}
}
}
}