Files
n8n-mcp/dist/mcp/server.js
Romuald Członkowski 562f4b0c4e feat: add _cnd conditional operator support and n8n 2.0+ executeWorkflowTrigger fix (#495)
* feat: add _cnd conditional operator support and n8n 2.0+ executeWorkflowTrigger fix

Added:
- Support for all 12 _cnd operators in displayOptions validation (eq, not, gte, lte, gt, lt, between, startsWith, endsWith, includes, regex, exists)
- Version-based visibility checking with @version in config
- 42 new unit tests for _cnd operators

Fixed:
- n8n 2.0+ breaking change: executeWorkflowTrigger now recognized as activatable trigger
- Removed outdated validation blocking Execute Workflow Trigger workflows

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: harden _cnd operators and add edge case tests

- Add try/catch for invalid regex patterns in regex operator
- Add structure validation for between operator (from/to fields)
- Add 5 new edge case tests for invalid inputs
- Bump version to 2.30.1
- Resolve merge conflict with main (n8n 2.0 update)

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: update workflow activation tests for n8n 2.0+ executeWorkflowTrigger

- Update test to expect SUCCESS for executeWorkflowTrigger-only workflows
- Remove outdated assertion about "executeWorkflowTrigger cannot activate"
- executeWorkflowTrigger is now a valid activatable trigger in n8n 2.0+

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: skip flaky versionId test pending n8n 2.0 investigation

The versionId behavior appears to have changed in n8n 2.0 - simple
name updates may no longer trigger versionId changes. This needs
investigation but is unrelated to the _cnd operator PR.

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Romuald Członkowski <romualdczlonkowski@MacBook-Pro-Romuald.local>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 18:37:55 +01:00

2824 lines
126 KiB
JavaScript

"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.N8NDocumentationMCPServer = void 0;
const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
const fs_1 = require("fs");
const path_1 = __importDefault(require("path"));
const tools_1 = require("./tools");
const tools_n8n_manager_1 = require("./tools-n8n-manager");
const tools_n8n_friendly_1 = require("./tools-n8n-friendly");
const workflow_examples_1 = require("./workflow-examples");
const logger_1 = require("../utils/logger");
const node_repository_1 = require("../database/node-repository");
const database_adapter_1 = require("../database/database-adapter");
const property_filter_1 = require("../services/property-filter");
const task_templates_1 = require("../services/task-templates");
const config_validator_1 = require("../services/config-validator");
const enhanced_config_validator_1 = require("../services/enhanced-config-validator");
const property_dependencies_1 = require("../services/property-dependencies");
const type_structure_service_1 = require("../services/type-structure-service");
const simple_cache_1 = require("../utils/simple-cache");
const template_service_1 = require("../templates/template-service");
const workflow_validator_1 = require("../services/workflow-validator");
const n8n_api_1 = require("../config/n8n-api");
const n8nHandlers = __importStar(require("./handlers-n8n-manager"));
const handlers_workflow_diff_1 = require("./handlers-workflow-diff");
const tools_documentation_1 = require("./tools-documentation");
const version_1 = require("../utils/version");
const node_utils_1 = require("../utils/node-utils");
const node_type_normalizer_1 = require("../utils/node-type-normalizer");
const validation_schemas_1 = require("../utils/validation-schemas");
const protocol_version_1 = require("../utils/protocol-version");
const telemetry_1 = require("../telemetry");
const startup_checkpoints_1 = require("../telemetry/startup-checkpoints");
class N8NDocumentationMCPServer {
constructor(instanceContext, earlyLogger) {
this.db = null;
this.repository = null;
this.templateService = null;
this.cache = new simple_cache_1.SimpleCache();
this.clientInfo = null;
this.previousTool = null;
this.previousToolTimestamp = Date.now();
this.earlyLogger = null;
this.disabledToolsCache = null;
this.dbHealthChecked = false;
this.instanceContext = instanceContext;
this.earlyLogger = earlyLogger || null;
const envDbPath = process.env.NODE_DB_PATH;
let dbPath = null;
let possiblePaths = [];
if (envDbPath && (envDbPath === ':memory:' || (0, fs_1.existsSync)(envDbPath))) {
dbPath = envDbPath;
}
else {
possiblePaths = [
path_1.default.join(process.cwd(), 'data', 'nodes.db'),
path_1.default.join(__dirname, '../../data', 'nodes.db'),
'./data/nodes.db'
];
for (const p of possiblePaths) {
if ((0, fs_1.existsSync)(p)) {
dbPath = p;
break;
}
}
}
if (!dbPath) {
logger_1.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.');
}
this.initialized = this.initializeDatabase(dbPath).then(() => {
if (this.earlyLogger) {
this.earlyLogger.logCheckpoint(startup_checkpoints_1.STARTUP_CHECKPOINTS.N8N_API_CHECKING);
}
const apiConfigured = (0, n8n_api_1.isN8nApiConfigured)();
const totalTools = apiConfigured ?
tools_1.n8nDocumentationToolsFinal.length + tools_n8n_manager_1.n8nManagementTools.length :
tools_1.n8nDocumentationToolsFinal.length;
logger_1.logger.info(`MCP server initialized with ${totalTools} tools (n8n API: ${apiConfigured ? 'configured' : 'not configured'})`);
if (this.earlyLogger) {
this.earlyLogger.logCheckpoint(startup_checkpoints_1.STARTUP_CHECKPOINTS.N8N_API_READY);
}
});
logger_1.logger.info('Initializing n8n Documentation MCP server');
this.server = new index_js_1.Server({
name: 'n8n-documentation-mcp',
version: version_1.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();
}
async close() {
try {
await this.server.close();
this.cache.destroy();
if (this.db) {
try {
this.db.close();
}
catch (dbError) {
logger_1.logger.warn('Error closing database', {
error: dbError instanceof Error ? dbError.message : String(dbError)
});
}
}
this.db = null;
this.repository = null;
this.templateService = null;
this.earlyLogger = null;
}
catch (error) {
logger_1.logger.warn('Error closing MCP server', { error: error instanceof Error ? error.message : String(error) });
}
}
async initializeDatabase(dbPath) {
try {
if (this.earlyLogger) {
this.earlyLogger.logCheckpoint(startup_checkpoints_1.STARTUP_CHECKPOINTS.DATABASE_CONNECTING);
}
logger_1.logger.debug('Database initialization starting...', { dbPath });
this.db = await (0, database_adapter_1.createDatabaseAdapter)(dbPath);
logger_1.logger.debug('Database adapter created');
if (dbPath === ':memory:') {
await this.initializeInMemorySchema();
logger_1.logger.debug('In-memory schema initialized');
}
this.repository = new node_repository_1.NodeRepository(this.db);
logger_1.logger.debug('Node repository initialized');
this.templateService = new template_service_1.TemplateService(this.db);
logger_1.logger.debug('Template service initialized');
enhanced_config_validator_1.EnhancedConfigValidator.initializeSimilarityServices(this.repository);
logger_1.logger.debug('Similarity services initialized');
if (this.earlyLogger) {
this.earlyLogger.logCheckpoint(startup_checkpoints_1.STARTUP_CHECKPOINTS.DATABASE_CONNECTED);
}
logger_1.logger.info(`Database initialized successfully from: ${dbPath}`);
}
catch (error) {
logger_1.logger.error('Failed to initialize database:', error);
throw new Error(`Failed to open database: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
async initializeInMemorySchema() {
if (!this.db)
return;
const schemaPath = path_1.default.join(__dirname, '../../src/database/schema.sql');
const schema = await fs_1.promises.readFile(schemaPath, 'utf-8');
const statements = this.parseSQLStatements(schema);
for (const statement of statements) {
if (statement.trim()) {
try {
this.db.exec(statement);
}
catch (error) {
logger_1.logger.error(`Failed to execute SQL statement: ${statement.substring(0, 100)}...`, error);
throw error;
}
}
}
}
parseSQLStatements(sql) {
const statements = [];
let current = '';
let inBlock = false;
const lines = sql.split('\n');
for (const line of lines) {
const trimmed = line.trim().toUpperCase();
if (trimmed.startsWith('--') || trimmed === '') {
continue;
}
if (trimmed.includes('BEGIN')) {
inBlock = true;
}
current += line + '\n';
if (inBlock && trimmed === 'END;') {
statements.push(current.trim());
current = '';
inBlock = false;
continue;
}
if (!inBlock && trimmed.endsWith(';')) {
statements.push(current.trim());
current = '';
}
}
if (current.trim()) {
statements.push(current.trim());
}
return statements.filter(s => s.length > 0);
}
async ensureInitialized() {
await this.initialized;
if (!this.db || !this.repository) {
throw new Error('Database not initialized');
}
if (!this.dbHealthChecked) {
await this.validateDatabaseHealth();
this.dbHealthChecked = true;
}
}
async validateDatabaseHealth() {
if (!this.db)
return;
try {
const nodeCount = this.db.prepare('SELECT COUNT(*) as count FROM nodes').get();
if (nodeCount.count === 0) {
logger_1.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.');
}
try {
const ftsExists = this.db.prepare(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='nodes_fts'
`).get();
if (!ftsExists) {
logger_1.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();
if (ftsCount.count === 0) {
logger_1.logger.warn('FTS5 index is empty - search will not work properly. Please run: npm run rebuild');
}
}
}
catch (ftsError) {
logger_1.logger.warn('FTS5 not available - using fallback search. For better performance, ensure better-sqlite3 is properly installed.');
}
logger_1.logger.info(`Database health check passed: ${nodeCount.count} nodes loaded`);
}
catch (error) {
logger_1.logger.error('Database health check failed:', error);
throw error;
}
}
getDisabledTools() {
if (this.disabledToolsCache !== null) {
return this.disabledToolsCache;
}
let disabledToolsEnv = process.env.DISABLED_TOOLS || '';
if (!disabledToolsEnv) {
this.disabledToolsCache = new Set();
return this.disabledToolsCache;
}
if (disabledToolsEnv.length > 10000) {
logger_1.logger.warn(`DISABLED_TOOLS environment variable too long (${disabledToolsEnv.length} chars), truncating to 10000`);
disabledToolsEnv = disabledToolsEnv.substring(0, 10000);
}
let tools = disabledToolsEnv
.split(',')
.map(t => t.trim())
.filter(Boolean);
if (tools.length > 200) {
logger_1.logger.warn(`DISABLED_TOOLS contains ${tools.length} tools, limiting to first 200`);
tools = tools.slice(0, 200);
}
if (tools.length > 0) {
logger_1.logger.info(`Disabled tools configured: ${tools.join(', ')}`);
}
this.disabledToolsCache = new Set(tools);
return this.disabledToolsCache;
}
setupHandlers() {
this.server.setRequestHandler(types_js_1.InitializeRequestSchema, async (request) => {
const clientVersion = request.params.protocolVersion;
const clientCapabilities = request.params.capabilities;
const clientInfo = request.params.clientInfo;
logger_1.logger.info('MCP Initialize request received', {
clientVersion,
clientCapabilities,
clientInfo
});
telemetry_1.telemetry.trackSessionStart();
this.clientInfo = clientInfo;
const negotiationResult = (0, protocol_version_1.negotiateProtocolVersion)(clientVersion, clientInfo, undefined, undefined);
(0, protocol_version_1.logProtocolNegotiation)(negotiationResult, logger_1.logger, 'MCP_INITIALIZE');
if (clientVersion && clientVersion !== negotiationResult.version) {
logger_1.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: version_1.PROJECT_VERSION,
},
};
logger_1.logger.info('MCP Initialize response', { response });
return response;
});
this.server.setRequestHandler(types_js_1.ListToolsRequestSchema, async (request) => {
const disabledTools = this.getDisabledTools();
const enabledDocTools = tools_1.n8nDocumentationToolsFinal.filter(tool => !disabledTools.has(tool.name));
let tools = [...enabledDocTools];
const hasEnvConfig = (0, n8n_api_1.isN8nApiConfigured)();
const hasInstanceConfig = !!(this.instanceContext?.n8nApiUrl && this.instanceContext?.n8nApiKey);
const isMultiTenantEnabled = process.env.ENABLE_MULTI_TENANT === 'true';
const shouldIncludeManagementTools = hasEnvConfig || hasInstanceConfig || isMultiTenantEnabled;
if (shouldIncludeManagementTools) {
const enabledMgmtTools = tools_n8n_manager_1.n8nManagementTools.filter(tool => !disabledTools.has(tool.name));
tools.push(...enabledMgmtTools);
logger_1.logger.debug(`Tool listing: ${tools.length} tools available (${enabledDocTools.length} documentation + ${enabledMgmtTools.length} management)`, {
hasEnvConfig,
hasInstanceConfig,
isMultiTenantEnabled,
disabledToolsCount: disabledTools.size
});
}
else {
logger_1.logger.debug(`Tool listing: ${tools.length} tools available (documentation only)`, {
hasEnvConfig,
hasInstanceConfig,
isMultiTenantEnabled,
disabledToolsCount: disabledTools.size
});
}
if (disabledTools.size > 0) {
const totalAvailableTools = tools_1.n8nDocumentationToolsFinal.length + (shouldIncludeManagementTools ? tools_n8n_manager_1.n8nManagementTools.length : 0);
logger_1.logger.debug(`Filtered ${disabledTools.size} disabled tools, ${tools.length}/${totalAvailableTools} tools available`);
}
const clientInfo = this.clientInfo;
const isN8nClient = clientInfo?.name?.includes('n8n') ||
clientInfo?.name?.includes('langchain');
if (isN8nClient) {
logger_1.logger.info('Detected n8n client, using n8n-friendly tool descriptions');
tools = (0, tools_n8n_friendly_1.makeToolsN8nFriendly)(tools);
}
const validationTools = tools.filter(t => t.name.startsWith('validate_'));
validationTools.forEach(tool => {
logger_1.logger.info('Validation tool schema', {
toolName: tool.name,
inputSchema: JSON.stringify(tool.inputSchema, null, 2),
hasOutputSchema: !!tool.outputSchema,
description: tool.description
});
});
return { tools };
});
this.server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
logger_1.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)
});
const disabledTools = this.getDisabledTools();
if (disabledTools.has(name)) {
logger_1.logger.warn(`Attempted to call disabled tool: ${name}`);
return {
content: [{
type: 'text',
text: JSON.stringify({
error: 'TOOL_DISABLED',
message: `Tool '${name}' is not available in this deployment. It has been disabled via DISABLED_TOOLS environment variable.`,
tool: name
}, null, 2)
}]
};
}
let processedArgs = args;
if (args && typeof args === 'object' && 'output' in args) {
try {
const possibleNestedData = args.output;
if (typeof possibleNestedData === 'string' && possibleNestedData.trim().startsWith('{')) {
const parsed = JSON.parse(possibleNestedData);
if (parsed && typeof parsed === 'object') {
logger_1.logger.warn('Detected n8n nested output bug, attempting to extract actual arguments', {
originalArgs: args,
extractedArgs: parsed
});
if (this.validateExtractedArgs(name, parsed)) {
processedArgs = parsed;
}
else {
logger_1.logger.warn('Extracted arguments failed validation, using original args', {
toolName: name,
extractedArgs: parsed
});
}
}
}
}
catch (parseError) {
logger_1.logger.debug('Failed to parse nested output, continuing with original args', {
error: parseError instanceof Error ? parseError.message : String(parseError)
});
}
}
try {
logger_1.logger.debug(`Executing tool: ${name}`, { args: processedArgs });
const startTime = Date.now();
const result = await this.executeTool(name, processedArgs);
const duration = Date.now() - startTime;
logger_1.logger.debug(`Tool ${name} executed successfully`);
telemetry_1.telemetry.trackToolUsage(name, true, duration);
if (this.previousTool) {
const timeDelta = Date.now() - this.previousToolTimestamp;
telemetry_1.telemetry.trackToolSequence(this.previousTool, name, timeDelta);
}
this.previousTool = name;
this.previousToolTimestamp = Date.now();
let responseText;
let structuredContent = null;
try {
if (name.startsWith('validate_') && typeof result === 'object' && result !== null) {
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_1.logger.warn(`Failed to stringify tool result for ${name}:`, jsonError);
responseText = String(result);
}
if (responseText.length > 1000000) {
logger_1.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;
}
const mcpResponse = {
content: [
{
type: 'text',
text: responseText,
},
],
};
if (name.startsWith('validate_') && structuredContent !== null) {
mcpResponse.structuredContent = structuredContent;
}
return mcpResponse;
}
catch (error) {
logger_1.logger.error(`Error executing tool ${name}`, error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
telemetry_1.telemetry.trackToolUsage(name, false);
telemetry_1.telemetry.trackError(error instanceof Error ? error.constructor.name : 'UnknownError', `tool_execution`, name, errorMessage);
if (this.previousTool) {
const timeDelta = Date.now() - this.previousToolTimestamp;
telemetry_1.telemetry.trackToolSequence(this.previousTool, name, timeDelta);
}
this.previousTool = name;
this.previousToolTimestamp = Date.now();
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.';
}
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,
};
}
});
}
sanitizeValidationResult(result, toolName) {
if (!result || typeof result !== 'object') {
return result;
}
const sanitized = { ...result };
if (toolName === 'validate_node_minimal') {
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') {
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
};
}
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);
sanitized.errors = Array.isArray(sanitized.errors) ? sanitized.errors : [];
sanitized.warnings = Array.isArray(sanitized.warnings) ? sanitized.warnings : [];
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
};
}
}
}
return JSON.parse(JSON.stringify(sanitized));
}
validateToolParams(toolName, args, legacyRequiredParams) {
try {
let validationResult;
switch (toolName) {
case 'validate_node':
validationResult = validation_schemas_1.ToolValidation.validateNodeOperation(args);
break;
case 'validate_workflow':
validationResult = validation_schemas_1.ToolValidation.validateWorkflow(args);
break;
case 'search_nodes':
validationResult = validation_schemas_1.ToolValidation.validateSearchNodes(args);
break;
case 'n8n_create_workflow':
validationResult = validation_schemas_1.ToolValidation.validateCreateWorkflow(args);
break;
case 'n8n_get_workflow':
case 'n8n_update_full_workflow':
case 'n8n_delete_workflow':
case 'n8n_validate_workflow':
case 'n8n_autofix_workflow':
validationResult = validation_schemas_1.ToolValidation.validateWorkflowId(args);
break;
case 'n8n_executions':
validationResult = args.action
? { valid: true, errors: [] }
: { valid: false, errors: [{ field: 'action', message: 'action is required' }] };
break;
case 'n8n_deploy_template':
validationResult = args.templateId !== undefined
? { valid: true, errors: [] }
: { valid: false, errors: [{ field: 'templateId', message: 'templateId is required' }] };
break;
default:
return this.validateToolParamsBasic(toolName, args, legacyRequiredParams || []);
}
if (!validationResult.valid) {
const errorMessage = validation_schemas_1.Validator.formatErrors(validationResult, toolName);
logger_1.logger.error(`Parameter validation failed for ${toolName}:`, errorMessage);
throw new validation_schemas_1.ValidationError(errorMessage);
}
}
catch (error) {
if (error instanceof validation_schemas_1.ValidationError) {
throw error;
}
logger_1.logger.error(`Validation system error for ${toolName}:`, error);
const errorMessage = error instanceof Error
? `Internal validation error: ${error.message}`
: `Internal validation error while processing ${toolName}`;
throw new Error(errorMessage);
}
}
validateToolParamsBasic(toolName, args, requiredParams) {
const missing = [];
const invalid = [];
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.`);
}
}
validateExtractedArgs(toolName, args) {
if (!args || typeof args !== 'object') {
return false;
}
const allTools = [...tools_1.n8nDocumentationToolsFinal, ...tools_n8n_manager_1.n8nManagementTools];
const tool = allTools.find(t => t.name === toolName);
if (!tool || !tool.inputSchema) {
return true;
}
const schema = tool.inputSchema;
const required = schema.required || [];
const properties = schema.properties || {};
for (const requiredField of required) {
if (!(requiredField in args)) {
logger_1.logger.debug(`Extracted args missing required field: ${requiredField}`, {
toolName,
extractedArgs: args,
required
});
return false;
}
}
for (const [fieldName, fieldValue] of Object.entries(args)) {
if (properties[fieldName]) {
const expectedType = properties[fieldName].type;
const actualType = Array.isArray(fieldValue) ? 'array' : typeof fieldValue;
if (expectedType && expectedType !== actualType) {
if (expectedType === 'number' && actualType === 'string' && !isNaN(Number(fieldValue))) {
continue;
}
logger_1.logger.debug(`Extracted args field type mismatch: ${fieldName}`, {
toolName,
expectedType,
actualType,
fieldValue
});
return 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_1.logger.debug(`Extracted args have extra fields`, {
toolName,
extraFields,
allowedFields
});
}
}
return true;
}
async executeTool(name, args) {
args = args || {};
const disabledTools = this.getDisabledTools();
if (disabledTools.has(name)) {
throw new Error(`Tool '${name}' is disabled via DISABLED_TOOLS environment variable`);
}
logger_1.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'
});
if (typeof args !== 'object' || args === null) {
throw new Error(`Invalid arguments for tool ${name}: expected object, got ${typeof args}`);
}
switch (name) {
case 'tools_documentation':
return this.getToolsDocumentation(args.topic, args.depth);
case 'search_nodes':
this.validateToolParams(name, args, ['query']);
const limit = args.limit !== undefined ? Number(args.limit) || 20 : 20;
return this.searchNodes(args.query, limit, { mode: args.mode, includeExamples: args.includeExamples });
case 'get_node':
this.validateToolParams(name, args, ['nodeType']);
if (args.mode === 'docs') {
return this.getNodeDocumentation(args.nodeType);
}
if (args.mode === 'search_properties') {
if (!args.propertyQuery) {
throw new Error('propertyQuery is required for mode=search_properties');
}
const maxResults = args.maxPropertyResults !== undefined ? Number(args.maxPropertyResults) || 20 : 20;
return this.searchNodeProperties(args.nodeType, args.propertyQuery, maxResults);
}
return this.getNode(args.nodeType, args.detail, args.mode, args.includeTypeInfo, args.includeExamples, args.fromVersion, args.toVersion);
case 'validate_node':
this.validateToolParams(name, args, ['nodeType', 'config']);
if (typeof args.config !== 'object' || args.config === null) {
logger_1.logger.warn(`validate_node called with invalid config type: ${typeof args.config}`);
const validationMode = args.mode || 'full';
if (validationMode === 'minimal') {
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 {
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 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
}
};
}
const validationMode = args.mode || 'full';
if (validationMode === 'minimal') {
return this.validateNodeMinimal(args.nodeType, args.config);
}
return this.validateNodeConfig(args.nodeType, args.config, 'operation', args.profile);
case 'get_template':
this.validateToolParams(name, args, ['templateId']);
const templateId = Number(args.templateId);
const templateMode = args.mode || 'full';
return this.getTemplate(templateId, templateMode);
case 'search_templates': {
const searchMode = args.searchMode || 'keyword';
const searchLimit = Math.min(Math.max(Number(args.limit) || 20, 1), 100);
const searchOffset = Math.max(Number(args.offset) || 0, 0);
switch (searchMode) {
case 'by_nodes':
if (!args.nodeTypes || !Array.isArray(args.nodeTypes) || args.nodeTypes.length === 0) {
throw new Error('nodeTypes array is required for searchMode=by_nodes');
}
return this.listNodeTemplates(args.nodeTypes, searchLimit, searchOffset);
case 'by_task':
if (!args.task) {
throw new Error('task is required for searchMode=by_task');
}
return this.getTemplatesForTask(args.task, searchLimit, searchOffset);
case 'by_metadata':
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
}, searchLimit, searchOffset);
case 'keyword':
default:
if (!args.query) {
throw new Error('query is required for searchMode=keyword');
}
const searchFields = args.fields;
return this.searchTemplates(args.query, searchLimit, searchOffset, searchFields);
}
}
case 'validate_workflow':
this.validateToolParams(name, args, ['workflow']);
return this.validateWorkflow(args.workflow, args.options);
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']);
const workflowMode = args.mode || 'full';
switch (workflowMode) {
case 'details':
return n8nHandlers.handleGetWorkflowDetails(args, this.instanceContext);
case 'structure':
return n8nHandlers.handleGetWorkflowStructure(args, this.instanceContext);
case 'minimal':
return n8nHandlers.handleGetWorkflowMinimal(args, this.instanceContext);
case 'full':
default:
return n8nHandlers.handleGetWorkflow(args, this.instanceContext);
}
}
case 'n8n_update_full_workflow':
this.validateToolParams(name, args, ['id']);
return n8nHandlers.handleUpdateWorkflow(args, this.repository, this.instanceContext);
case 'n8n_update_partial_workflow':
this.validateToolParams(name, args, ['id', 'operations']);
return (0, handlers_workflow_diff_1.handleUpdatePartialWorkflow)(args, this.repository, this.instanceContext);
case 'n8n_delete_workflow':
this.validateToolParams(name, args, ['id']);
return n8nHandlers.handleDeleteWorkflow(args, this.instanceContext);
case 'n8n_list_workflows':
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_test_workflow':
this.validateToolParams(name, args, ['workflowId']);
return n8nHandlers.handleTestWorkflow(args, this.instanceContext);
case 'n8n_executions': {
this.validateToolParams(name, args, ['action']);
const execAction = args.action;
switch (execAction) {
case 'get':
if (!args.id) {
throw new Error('id is required for action=get');
}
return n8nHandlers.handleGetExecution(args, this.instanceContext);
case 'list':
return n8nHandlers.handleListExecutions(args, this.instanceContext);
case 'delete':
if (!args.id) {
throw new Error('id is required for action=delete');
}
return n8nHandlers.handleDeleteExecution(args, this.instanceContext);
default:
throw new Error(`Unknown action: ${execAction}. Valid actions: get, list, delete`);
}
}
case 'n8n_health_check':
if (args.mode === 'diagnostic') {
return n8nHandlers.handleDiagnostic({ params: { arguments: args } }, this.instanceContext);
}
return n8nHandlers.handleHealthCheck(this.instanceContext);
case 'n8n_workflow_versions':
this.validateToolParams(name, args, ['mode']);
return n8nHandlers.handleWorkflowVersions(args, this.repository, this.instanceContext);
case 'n8n_deploy_template':
this.validateToolParams(name, args, ['templateId']);
await this.ensureInitialized();
if (!this.templateService)
throw new Error('Template service not initialized');
if (!this.repository)
throw new Error('Repository not initialized');
return n8nHandlers.handleDeployTemplate(args, this.templateService, this.repository, this.instanceContext);
default:
throw new Error(`Unknown tool: ${name}`);
}
}
async listNodes(filters = {}) {
await this.ensureInitialized();
let query = 'SELECT * FROM nodes WHERE 1=1';
const params = [];
if (filters.package) {
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);
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,
};
}
async getNodeInfo(nodeType) {
await this.ensureInitialized();
if (!this.repository)
throw new Error('Repository not initialized');
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(nodeType);
let node = this.repository.getNode(normalizedType);
if (!node && normalizedType !== nodeType) {
node = this.repository.getNode(nodeType);
}
if (!node) {
const alternatives = (0, node_utils_1.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`);
}
const aiToolCapabilities = {
canBeUsedAsTool: true,
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
};
let outputs = undefined;
if (node.outputNames && Array.isArray(node.outputNames) && node.outputNames.length > 0) {
outputs = node.outputNames.map((name, index) => {
const descriptions = this.getOutputDescriptions(node.nodeType, name, index);
return {
index,
name,
description: descriptions?.description ?? '',
connectionGuidance: descriptions?.connectionGuidance ?? ''
};
});
}
const result = {
...node,
workflowNodeType: (0, node_utils_1.getWorkflowNodeType)(node.package ?? 'n8n-nodes-base', node.nodeType),
aiToolCapabilities,
outputs
};
const toolVariantInfo = this.buildToolVariantGuidance(node);
if (toolVariantInfo) {
result.toolVariantInfo = toolVariantInfo;
}
return result;
}
async searchNodes(query, limit = 20, options) {
await this.ensureInitialized();
if (!this.db)
throw new Error('Database not initialized');
let normalizedQuery = query;
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';
const ftsExists = this.db.prepare(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='nodes_fts'
`).get();
if (ftsExists) {
logger_1.logger.debug(`Using FTS5 search with includeExamples=${options?.includeExamples}`);
return this.searchNodesFTS(normalizedQuery, limit, searchMode, options);
}
else {
logger_1.logger.debug('Using LIKE search (no FTS5)');
return this.searchNodesLIKE(normalizedQuery, limit, options);
}
}
async searchNodesFTS(query, limit, mode, options) {
if (!this.db)
throw new Error('Database not initialized');
const cleanedQuery = query.trim();
if (!cleanedQuery) {
return { query, results: [], totalCount: 0 };
}
if (mode === 'FUZZY') {
return this.searchNodesFuzzy(cleanedQuery, limit);
}
let ftsQuery;
if (cleanedQuery.startsWith('"') && cleanedQuery.endsWith('"')) {
ftsQuery = cleanedQuery;
}
else {
const words = cleanedQuery.split(/\s+/).filter(w => w.length > 0);
switch (mode) {
case 'AND':
ftsQuery = words.join(' AND ');
break;
case 'OR':
default:
ftsQuery = words.join(' OR ');
break;
}
}
try {
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
CASE
WHEN LOWER(n.display_name) = LOWER(?) THEN 0
WHEN LOWER(n.display_name) LIKE LOWER(?) THEN 1
WHEN LOWER(n.node_type) LIKE LOWER(?) THEN 2
ELSE 3
END,
rank,
n.display_name
LIMIT ?
`).all(ftsQuery, cleanedQuery, `%${cleanedQuery}%`, `%${cleanedQuery}%`, limit);
const scoredNodes = nodes.map(node => {
const relevanceScore = this.calculateRelevanceScore(node, cleanedQuery);
return { ...node, relevanceScore };
});
scoredNodes.sort((a, b) => {
if (a.display_name.toLowerCase() === cleanedQuery.toLowerCase())
return -1;
if (b.display_name.toLowerCase() === cleanedQuery.toLowerCase())
return 1;
if (a.relevanceScore !== b.relevanceScore) {
return b.relevanceScore - a.relevanceScore;
}
return a.rank - b.rank;
});
const hasHttpRequest = scoredNodes.some(n => n.node_type === 'nodes-base.httpRequest');
if (cleanedQuery.toLowerCase().includes('http') && !hasHttpRequest) {
logger_1.logger.debug('FTS missed HTTP Request node, augmenting with LIKE search');
return this.searchNodesLIKE(query, limit);
}
const result = {
query,
results: scoredNodes.map(node => ({
nodeType: node.node_type,
workflowNodeType: (0, node_utils_1.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
};
if (mode !== 'OR') {
result.mode = mode;
}
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);
if (examples.length > 0) {
nodeResult.examples = examples.map((ex) => ({
configuration: JSON.parse(ex.parameters_json),
template: ex.template_name,
views: ex.template_views
}));
}
}
}
catch (error) {
logger_1.logger.error(`Failed to add examples:`, error);
}
}
telemetry_1.telemetry.trackSearchQuery(query, scoredNodes.length, mode ?? 'OR');
return result;
}
catch (error) {
logger_1.logger.warn('FTS5 search failed, falling back to LIKE search:', error.message);
if (error.message.includes('syntax error') || error.message.includes('fts5')) {
logger_1.logger.warn(`FTS5 syntax error for query "${query}" in mode ${mode}`);
const likeResult = await this.searchNodesLIKE(query, limit);
telemetry_1.telemetry.trackSearchQuery(query, likeResult.results?.length ?? 0, `${mode}_LIKE_FALLBACK`);
return {
...likeResult,
mode
};
}
return this.searchNodesLIKE(query, limit);
}
}
async searchNodesFuzzy(query, limit) {
if (!this.db)
throw new Error('Database not initialized');
const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 0);
if (words.length === 0) {
return { query, results: [], totalCount: 0, mode: 'FUZZY' };
}
const candidateNodes = this.db.prepare(`
SELECT * FROM nodes
`).all();
const scoredNodes = candidateNodes.map(node => {
const score = this.calculateFuzzyScore(node, query);
return { node, score };
});
const matchingNodes = scoredNodes
.filter(item => item.score >= 200)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(item => item.node);
if (matchingNodes.length === 0) {
const topScores = scoredNodes
.sort((a, b) => b.score - a.score)
.slice(0, 5);
logger_1.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: (0, node_utils_1.getWorkflowNodeType)(node.package_name, node.node_type),
displayName: node.display_name,
description: node.description,
category: node.category,
package: node.package_name
})),
totalCount: matchingNodes.length
};
}
calculateFuzzyScore(node, query) {
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\./, '');
if (displayNameLower === queryLower || nodeTypeClean === queryLower) {
return 1000;
}
const nameDistance = this.getEditDistance(queryLower, displayNameLower);
const typeDistance = this.getEditDistance(queryLower, nodeTypeClean);
const nameWords = displayNameLower.split(/\s+/);
let minWordDistance = Infinity;
for (const word of nameWords) {
const distance = this.getEditDistance(queryLower, word);
if (distance < minWordDistance) {
minWordDistance = distance;
}
}
const bestDistance = Math.min(nameDistance, typeDistance, minWordDistance);
let matchedLen = queryLower.length;
if (minWordDistance === bestDistance) {
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);
if (displayNameLower.includes(queryLower) || nodeTypeClean.includes(queryLower)) {
return 800 + (similarity * 100);
}
if (displayNameLower.startsWith(queryLower) ||
nodeTypeClean.startsWith(queryLower) ||
nameWords.some(w => w.startsWith(queryLower))) {
return 700 + (similarity * 100);
}
if (bestDistance <= 2) {
return 500 + ((2 - bestDistance) * 100) + (similarity * 50);
}
if (bestDistance <= 3 && queryLower.length >= 4) {
return 400 + ((3 - bestDistance) * 50) + (similarity * 50);
}
return similarity * 300;
}
getEditDistance(s1, s2) {
const m = s1.length;
const n = s2.length;
const dp = 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];
}
async searchNodesLIKE(query, limit, options) {
if (!this.db)
throw new Error('Database not initialized');
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);
const rankedNodes = this.rankSearchResults(nodes, exactPhrase, limit);
const result = {
query,
results: rankedNodes.map(node => ({
nodeType: node.node_type,
workflowNodeType: (0, node_utils_1.getWorkflowNodeType)(node.package_name, node.node_type),
displayName: node.display_name,
description: node.description,
category: node.category,
package: node.package_name
})),
totalCount: rankedNodes.length
};
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);
if (examples.length > 0) {
nodeResult.examples = examples.map((ex) => ({
configuration: JSON.parse(ex.parameters_json),
template: ex.template_name,
views: ex.template_views
}));
}
}
catch (error) {
logger_1.logger.warn(`Failed to fetch examples for ${nodeResult.nodeType}:`, error.message);
}
}
}
return result;
}
const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 0);
if (words.length === 0) {
return { query, results: [], totalCount: 0 };
}
const conditions = words.map(() => '(node_type LIKE ? OR display_name LIKE ? OR description LIKE ?)').join(' OR ');
const params = words.flatMap(w => [`%${w}%`, `%${w}%`, `%${w}%`]);
params.push(limit * 3);
const nodes = this.db.prepare(`
SELECT DISTINCT * FROM nodes
WHERE ${conditions}
LIMIT ?
`).all(...params);
const rankedNodes = this.rankSearchResults(nodes, query, limit);
const result = {
query,
results: rankedNodes.map(node => ({
nodeType: node.node_type,
workflowNodeType: (0, node_utils_1.getWorkflowNodeType)(node.package_name, node.node_type),
displayName: node.display_name,
description: node.description,
category: node.category,
package: node.package_name
})),
totalCount: rankedNodes.length
};
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);
if (examples.length > 0) {
nodeResult.examples = examples.map((ex) => ({
configuration: JSON.parse(ex.parameters_json),
template: ex.template_name,
views: ex.template_views
}));
}
}
catch (error) {
logger_1.logger.warn(`Failed to fetch examples for ${nodeResult.nodeType}:`, error.message);
}
}
}
return result;
}
calculateRelevance(node, query) {
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';
}
calculateRelevanceScore(node, query) {
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;
if (name_lower === query_lower) {
score = 1000;
}
else if (type_without_prefix === query_lower) {
score = 950;
}
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;
}
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;
}
else if (query_lower.includes('webhook') && node.node_type === 'nodes-base.webhook') {
score = 850;
}
else if (name_lower.startsWith(query_lower)) {
score = 800;
}
else if (new RegExp(`\\b${query_lower}\\b`, 'i').test(node.display_name)) {
score = 700;
}
else if (name_lower.includes(query_lower)) {
score = 600;
}
else if (type_without_prefix.includes(query_lower)) {
score = 500;
}
else if (node.description?.toLowerCase().includes(query_lower)) {
score = 400;
}
return score;
}
rankSearchResults(nodes, query, limit) {
const query_lower = query.toLowerCase();
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;
if (name_lower === query_lower) {
score = 1000;
}
else if (type_without_prefix === query_lower) {
score = 950;
}
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;
}
else if (query_lower.includes('webhook') && node.node_type === 'nodes-base.webhook') {
score = 850;
}
else if (query_lower.includes('http') && node.node_type === 'nodes-base.httpRequest') {
score = 850;
}
else if (name_lower.startsWith(query_lower)) {
score = 800;
}
else if (new RegExp(`\\b${query_lower}\\b`, 'i').test(node.display_name)) {
score = 700;
}
else if (name_lower.includes(query_lower)) {
score = 600;
}
else if (type_without_prefix.includes(query_lower)) {
score = 500;
}
else if (node.description?.toLowerCase().includes(query_lower)) {
score = 400;
}
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;
if (query_lower === 'http call' && name_lower === 'http request') {
score = 920;
}
}
return { node, score };
});
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 scoredNodes.slice(0, limit).map(item => item.node);
}
async listAITools() {
await this.ensureInitialized();
if (!this.repository)
throw new Error('Repository not initialized');
const tools = this.repository.getAITools();
const aiCount = this.db.prepare('SELECT COUNT(*) as ai_count FROM nodes WHERE is_ai_tool = 1').get();
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'
]
}
};
}
async getNodeDocumentation(nodeType) {
await this.ensureInitialized();
if (!this.db)
throw new Error('Database not initialized');
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(nodeType);
let node = this.db.prepare(`
SELECT node_type, display_name, documentation, description
FROM nodes
WHERE node_type = ?
`).get(normalizedType);
if (!node && normalizedType !== nodeType) {
node = this.db.prepare(`
SELECT node_type, display_name, documentation, description
FROM nodes
WHERE node_type = ?
`).get(nodeType);
}
if (!node) {
const alternatives = (0, node_utils_1.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);
if (node)
break;
}
}
if (!node) {
throw new Error(`Node ${nodeType} not found`);
}
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) => `### ${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,
};
}
async getDatabaseStatistics() {
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();
const packages = this.db.prepare(`
SELECT package_name, COUNT(*) as count
FROM nodes
GROUP BY package_name
`).all();
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();
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,
})),
};
}
async getNodeEssentials(nodeType, includeExamples) {
await this.ensureInitialized();
if (!this.repository)
throw new Error('Repository not initialized');
const cacheKey = `essentials:${nodeType}:${includeExamples ? 'withExamples' : 'basic'}`;
const cached = this.cache.get(cacheKey);
if (cached)
return cached;
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(nodeType);
let node = this.repository.getNode(normalizedType);
if (!node && normalizedType !== nodeType) {
node = this.repository.getNode(nodeType);
}
if (!node) {
const alternatives = (0, node_utils_1.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`);
}
const allProperties = node.properties || [];
const essentials = property_filter_1.PropertyFilter.getEssentials(allProperties, node.nodeType);
const operations = node.operations || [];
const latestVersion = node.version ?? '1';
const result = {
nodeType: node.nodeType,
workflowNodeType: (0, node_utils_1.getWorkflowNodeType)(node.package ?? 'n8n-nodes-base', node.nodeType),
displayName: node.displayName,
description: node.description,
category: node.category,
version: latestVersion,
isVersioned: node.isVersioned ?? false,
versionNotice: `⚠️ Use typeVersion: ${latestVersion} when creating this node`,
requiredProperties: essentials.required,
commonProperties: essentials.common,
operations: operations.map((op) => ({
name: op.name || op.operation,
description: op.description,
action: op.action,
resource: op.resource
})),
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'
}
};
const toolVariantInfo = this.buildToolVariantGuidance(node);
if (toolVariantInfo) {
result.toolVariantInfo = toolVariantInfo;
}
if (includeExamples) {
try {
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);
if (examples.length > 0) {
result.examples = examples.map((ex) => ({
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.examplesCount = examples.length;
}
else {
result.examples = [];
result.examplesCount = 0;
}
}
catch (error) {
logger_1.logger.warn(`Failed to fetch examples for ${nodeType}:`, error.message);
result.examples = [];
result.examplesCount = 0;
}
}
this.cache.set(cacheKey, result, 3600);
return result;
}
async getNode(nodeType, detail = 'standard', mode = 'info', includeTypeInfo, includeExamples, fromVersion, toVersion) {
await this.ensureInitialized();
if (!this.repository)
throw new Error('Repository not initialized');
const validDetailLevels = ['minimal', 'standard', 'full'];
const validModes = ['info', 'versions', 'compare', 'breaking', 'migrations'];
if (!validDetailLevels.includes(detail)) {
throw new Error(`get_node: Invalid detail level "${detail}". Valid options: ${validDetailLevels.join(', ')}`);
}
if (!validModes.includes(mode)) {
throw new Error(`get_node: Invalid mode "${mode}". Valid options: ${validModes.join(', ')}`);
}
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(nodeType);
if (mode !== 'info') {
return this.handleVersionMode(normalizedType, mode, fromVersion, toVersion);
}
return this.handleInfoMode(normalizedType, detail, includeTypeInfo, includeExamples);
}
async handleInfoMode(nodeType, detail, includeTypeInfo, includeExamples) {
switch (detail) {
case 'minimal': {
let node = this.repository.getNode(nodeType);
if (!node) {
const alternatives = (0, node_utils_1.getNodeTypeAlternatives)(nodeType);
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`);
}
const result = {
nodeType: node.nodeType,
workflowNodeType: (0, node_utils_1.getWorkflowNodeType)(node.package ?? 'n8n-nodes-base', node.nodeType),
displayName: node.displayName,
description: node.description,
category: node.category,
package: node.package,
isAITool: node.isAITool,
isTrigger: node.isTrigger,
isWebhook: node.isWebhook
};
const toolVariantInfo = this.buildToolVariantGuidance(node);
if (toolVariantInfo) {
result.toolVariantInfo = toolVariantInfo;
}
return result;
}
case 'standard': {
const essentials = await this.getNodeEssentials(nodeType, includeExamples);
const versionSummary = this.getVersionSummary(nodeType);
if (includeTypeInfo) {
essentials.requiredProperties = this.enrichPropertiesWithTypeInfo(essentials.requiredProperties);
essentials.commonProperties = this.enrichPropertiesWithTypeInfo(essentials.commonProperties);
}
return {
...essentials,
versionInfo: versionSummary
};
}
case 'full': {
const fullInfo = await this.getNodeInfo(nodeType);
const versionSummary = this.getVersionSummary(nodeType);
if (includeTypeInfo && fullInfo.properties) {
fullInfo.properties = this.enrichPropertiesWithTypeInfo(fullInfo.properties);
}
return {
...fullInfo,
versionInfo: versionSummary
};
}
default:
throw new Error(`Unknown detail level: ${detail}`);
}
}
async handleVersionMode(nodeType, mode, fromVersion, toVersion) {
switch (mode) {
case 'versions':
return this.getVersionHistory(nodeType);
case 'compare':
if (!fromVersion) {
throw new Error(`get_node: fromVersion is required for compare mode (nodeType: ${nodeType})`);
}
return this.compareVersions(nodeType, fromVersion, toVersion);
case 'breaking':
if (!fromVersion) {
throw new Error(`get_node: fromVersion is required for breaking mode (nodeType: ${nodeType})`);
}
return this.getBreakingChanges(nodeType, fromVersion, toVersion);
case 'migrations':
if (!fromVersion || !toVersion) {
throw new Error(`get_node: Both fromVersion and toVersion are required for migrations mode (nodeType: ${nodeType})`);
}
return this.getMigrations(nodeType, fromVersion, toVersion);
default:
throw new Error(`get_node: Unknown mode: ${mode} (nodeType: ${nodeType})`);
}
}
getVersionSummary(nodeType) {
const cacheKey = `version-summary:${nodeType}`;
const cached = this.cache.get(cacheKey);
if (cached) {
return cached;
}
const versions = this.repository.getNodeVersions(nodeType);
const latest = this.repository.getLatestNodeVersion(nodeType);
const summary = {
currentVersion: latest?.version || 'unknown',
totalVersions: versions.length,
hasVersionHistory: versions.length > 0
};
this.cache.set(cacheKey, summary, 86400000);
return summary;
}
getVersionHistory(nodeType) {
const versions = this.repository.getNodeVersions(nodeType);
return {
nodeType,
totalVersions: versions.length,
versions: versions.map(v => ({
version: v.version,
isCurrent: v.isCurrentMax,
minimumN8nVersion: v.minimumN8nVersion,
releasedAt: v.releasedAt,
hasBreakingChanges: (v.breakingChanges || []).length > 0,
breakingChangesCount: (v.breakingChanges || []).length,
deprecatedProperties: v.deprecatedProperties || [],
addedProperties: v.addedProperties || []
})),
available: versions.length > 0,
message: versions.length === 0 ?
'No version history available. Version tracking may not be enabled for this node.' :
undefined
};
}
compareVersions(nodeType, fromVersion, toVersion) {
const latest = this.repository.getLatestNodeVersion(nodeType);
const targetVersion = toVersion || latest?.version;
if (!targetVersion) {
throw new Error('No target version available');
}
const changes = this.repository.getPropertyChanges(nodeType, fromVersion, targetVersion);
return {
nodeType,
fromVersion,
toVersion: targetVersion,
totalChanges: changes.length,
breakingChanges: changes.filter(c => c.isBreaking).length,
changes: changes.map(c => ({
property: c.propertyName,
changeType: c.changeType,
isBreaking: c.isBreaking,
severity: c.severity,
oldValue: c.oldValue,
newValue: c.newValue,
migrationHint: c.migrationHint,
autoMigratable: c.autoMigratable
}))
};
}
getBreakingChanges(nodeType, fromVersion, toVersion) {
const breakingChanges = this.repository.getBreakingChanges(nodeType, fromVersion, toVersion);
return {
nodeType,
fromVersion,
toVersion: toVersion || 'latest',
totalBreakingChanges: breakingChanges.length,
changes: breakingChanges.map(c => ({
fromVersion: c.fromVersion,
toVersion: c.toVersion,
property: c.propertyName,
changeType: c.changeType,
severity: c.severity,
migrationHint: c.migrationHint,
oldValue: c.oldValue,
newValue: c.newValue
})),
upgradeSafe: breakingChanges.length === 0
};
}
getMigrations(nodeType, fromVersion, toVersion) {
const migrations = this.repository.getAutoMigratableChanges(nodeType, fromVersion, toVersion);
const allChanges = this.repository.getPropertyChanges(nodeType, fromVersion, toVersion);
return {
nodeType,
fromVersion,
toVersion,
autoMigratableChanges: migrations.length,
totalChanges: allChanges.length,
migrations: migrations.map(m => ({
property: m.propertyName,
changeType: m.changeType,
migrationStrategy: m.migrationStrategy,
severity: m.severity
})),
requiresManualMigration: migrations.length < allChanges.length
};
}
enrichPropertyWithTypeInfo(property) {
if (!property || !property.type)
return property;
const structure = type_structure_service_1.TypeStructureService.getStructure(property.type);
if (!structure)
return property;
return {
...property,
typeInfo: {
category: structure.type,
jsType: structure.jsType,
description: structure.description,
isComplex: type_structure_service_1.TypeStructureService.isComplexType(property.type),
isPrimitive: type_structure_service_1.TypeStructureService.isPrimitiveType(property.type),
allowsExpressions: structure.validation?.allowExpressions ?? true,
allowsEmpty: structure.validation?.allowEmpty ?? false,
...(structure.structure && {
structureHints: {
hasProperties: !!structure.structure.properties,
hasItems: !!structure.structure.items,
isFlexible: structure.structure.flexible ?? false,
requiredFields: structure.structure.required ?? []
}
}),
...(structure.notes && { notes: structure.notes })
}
};
}
enrichPropertiesWithTypeInfo(properties) {
if (!properties || !Array.isArray(properties))
return properties;
return properties.map((prop) => this.enrichPropertyWithTypeInfo(prop));
}
async searchNodeProperties(nodeType, query, maxResults = 20) {
await this.ensureInitialized();
if (!this.repository)
throw new Error('Repository not initialized');
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(nodeType);
let node = this.repository.getNode(normalizedType);
if (!node && normalizedType !== nodeType) {
node = this.repository.getNode(nodeType);
}
if (!node) {
const alternatives = (0, node_utils_1.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`);
}
const allProperties = node.properties || [];
const matches = property_filter_1.PropertyFilter.searchProperties(allProperties, query, maxResults);
return {
nodeType: node.nodeType,
query,
matches: matches.map((match) => ({
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'
};
}
getPropertyValue(config, path) {
const parts = path.split('.');
let value = config;
for (const part of parts) {
const arrayMatch = part.match(/^(\w+)\[(\d+)\]$/);
if (arrayMatch) {
value = value?.[arrayMatch[1]]?.[parseInt(arrayMatch[2])];
}
else {
value = value?.[part];
}
}
return value;
}
async listTasks(category) {
if (category) {
const categories = task_templates_1.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 = task_templates_1.TaskTemplates.getTaskTemplate(task);
return {
task,
description: template?.description || '',
nodeType: template?.nodeType || ''
};
})
};
}
const categories = task_templates_1.TaskTemplates.getTaskCategories();
const result = {
totalTasks: task_templates_1.TaskTemplates.getAllTasks().length,
categories: {}
};
for (const [cat, tasks] of Object.entries(categories)) {
result.categories[cat] = tasks.map(task => {
const template = task_templates_1.TaskTemplates.getTaskTemplate(task);
return {
task,
description: template?.description || '',
nodeType: template?.nodeType || ''
};
});
}
return result;
}
async validateNodeConfig(nodeType, config, mode = 'operation', profile = 'ai-friendly') {
await this.ensureInitialized();
if (!this.repository)
throw new Error('Repository not initialized');
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(nodeType);
let node = this.repository.getNode(normalizedType);
if (!node && normalizedType !== nodeType) {
node = this.repository.getNode(nodeType);
}
if (!node) {
const alternatives = (0, node_utils_1.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`);
}
const properties = node.properties || [];
const configWithVersion = {
'@version': node.version || 1,
...config
};
const validationResult = enhanced_config_validator_1.EnhancedConfigValidator.validateWithMode(node.nodeType, configWithVersion, properties, mode, profile);
return {
nodeType: node.nodeType,
workflowNodeType: (0, node_utils_1.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
}
};
}
async getPropertyDependencies(nodeType, config) {
await this.ensureInitialized();
if (!this.repository)
throw new Error('Repository not initialized');
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(nodeType);
let node = this.repository.getNode(normalizedType);
if (!node && normalizedType !== nodeType) {
node = this.repository.getNode(nodeType);
}
if (!node) {
const alternatives = (0, node_utils_1.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`);
}
const properties = node.properties || [];
const analysis = property_dependencies_1.PropertyDependencies.analyze(properties);
let visibilityImpact = null;
if (config) {
visibilityImpact = property_dependencies_1.PropertyDependencies.getVisibilityImpact(properties, config);
}
return {
nodeType: node.nodeType,
displayName: node.displayName,
...analysis,
currentConfig: config ? {
providedValues: config,
visibilityImpact
} : undefined
};
}
async getNodeAsToolInfo(nodeType) {
await this.ensureInitialized();
if (!this.repository)
throw new Error('Repository not initialized');
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(nodeType);
let node = this.repository.getNode(normalizedType);
if (!node && normalizedType !== nodeType) {
node = this.repository.getNode(nodeType);
}
if (!node) {
const alternatives = (0, node_utils_1.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`);
}
const commonUseCases = this.getCommonAIToolUseCases(node.nodeType);
const aiToolCapabilities = {
canBeUsedAsTool: true,
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: (0, node_utils_1.getWorkflowNodeType)(node.package, node.nodeType),
displayName: node.displayName,
description: node.description,
package: node.package,
isMarkedAsAITool: node.isAITool,
aiToolCapabilities
};
}
getOutputDescriptions(nodeType, outputName, index) {
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)'
};
}
}
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'
};
}
}
if (nodeType === 'nodes-base.switch') {
return {
description: `Output ${index}: ${outputName || 'Route ' + index}`,
connectionGuidance: `Connect to nodes for the "${outputName || 'route ' + index}" case`
};
}
return {
description: outputName || `Output ${index}`,
connectionGuidance: `Connect to downstream nodes`
};
}
getCommonAIToolUseCases(nodeType) {
const useCaseMap = {
'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'
]
};
for (const [key, useCases] of Object.entries(useCaseMap)) {
if (nodeType.includes(key)) {
return useCases;
}
}
return [
'Perform automated actions',
'Integrate with external services',
'Process and transform data',
'Extend AI agent capabilities'
];
}
buildToolVariantGuidance(node) {
const isToolVariant = !!node.isToolVariant;
const hasToolVariant = !!node.hasToolVariant;
const toolVariantOf = node.toolVariantOf;
if (!isToolVariant && !hasToolVariant) {
return undefined;
}
if (isToolVariant) {
return {
isToolVariant: true,
toolVariantOf,
hasToolVariant: false,
guidance: `This is the Tool variant for AI Agent integration. Use this node type when connecting to AI Agents. The base node is: ${toolVariantOf}`
};
}
if (hasToolVariant && node.nodeType) {
const toolVariantNodeType = `${node.nodeType}Tool`;
return {
isToolVariant: false,
hasToolVariant: true,
toolVariantNodeType,
guidance: `To use this node with AI Agents, use the Tool variant: ${toolVariantNodeType}. The Tool variant has an additional 'toolDescription' property and outputs 'ai_tool' instead of 'main'.`
};
}
return undefined;
}
getAIToolExamples(nodeType) {
const exampleMap = {
'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") }}'
}
}
};
for (const [key, example] of Object.entries(exampleMap)) {
if (nodeType.includes(key)) {
return 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'
}
};
}
async validateNodeMinimal(nodeType, config) {
await this.ensureInitialized();
if (!this.repository)
throw new Error('Repository not initialized');
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(nodeType);
let node = this.repository.getNode(normalizedType);
if (!node && normalizedType !== nodeType) {
node = this.repository.getNode(nodeType);
}
if (!node) {
const alternatives = (0, node_utils_1.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`);
}
const properties = node.properties || [];
const configWithVersion = {
'@version': node.version || 1,
...(config || {})
};
const missingFields = [];
for (const prop of properties) {
if (!prop.required)
continue;
if (prop.displayOptions && !config_validator_1.ConfigValidator.isPropertyVisible(prop, configWithVersion)) {
continue;
}
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
};
}
async getToolsDocumentation(topic, depth = 'essentials') {
if (!topic || topic === 'overview') {
return (0, tools_documentation_1.getToolsOverview)(depth);
}
return (0, tools_documentation_1.getToolDocumentation)(topic, depth);
}
async connect(transport) {
await this.ensureInitialized();
await this.server.connect(transport);
logger_1.logger.info('MCP Server connected', {
transportType: transport.constructor.name
});
}
async listTemplates(limit = 10, offset = 0, sortBy = 'views', includeMetadata = false) {
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"
};
}
async listNodeTemplates(nodeTypes, limit = 10, offset = 0) {
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.`
};
}
async getTemplate(templateId, mode = 'full') {
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
};
}
async searchTemplates(query, limit = 20, offset = 0, fields) {
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}.`
};
}
async getTemplatesForTask(task, limit = 10, offset = 0) {
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}.`
};
}
async searchTemplatesByMetadata(filters, limit = 20, offset = 0) {
await this.ensureInitialized();
if (!this.templateService)
throw new Error('Template service not initialized');
const result = await this.templateService.searchTemplatesByMetadata(filters, limit, offset);
const filterSummary = [];
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) {
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.`
};
}
getTaskDescription(task) {
const descriptions = {
'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';
}
async validateWorkflow(workflow, options) {
await this.ensureInitialized();
if (!this.repository)
throw new Error('Repository not initialized');
logger_1.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
});
if (!workflow || typeof workflow !== 'object') {
return {
valid: false,
errors: [{
node: 'workflow',
message: 'Workflow must be an object with nodes and connections',
details: 'Expected format: ' + (0, workflow_examples_1.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]. ' + (0, workflow_examples_1.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). ' + (0, workflow_examples_1.getWorkflowExampleString)()
}],
summary: { errorCount: 1 }
};
}
const validator = new workflow_validator_1.WorkflowValidator(this.repository, enhanced_config_validator_1.EnhancedConfigValidator);
try {
const result = await validator.validateWorkflow(workflow, options);
const response = {
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
},
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;
}
if (!result.valid && result.errors.length > 0) {
result.errors.forEach(error => {
telemetry_1.telemetry.trackValidationDetails(error.nodeName || 'workflow', error.type || 'validation_error', {
message: error.message,
nodeCount: workflow.nodes?.length ?? 0,
hasConnections: Object.keys(workflow.connections || {}).length > 0
});
});
}
if (result.valid) {
telemetry_1.telemetry.trackWorkflowCreation(workflow, true);
}
return response;
}
catch (error) {
logger_1.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'
};
}
}
async validateWorkflowConnections(workflow) {
await this.ensureInitialized();
if (!this.repository)
throw new Error('Repository not initialized');
const validator = new workflow_validator_1.WorkflowValidator(this.repository, enhanced_config_validator_1.EnhancedConfigValidator);
try {
const result = await validator.validateWorkflow(workflow, {
validateNodes: false,
validateConnections: true,
validateExpressions: false
});
const response = {
valid: result.errors.length === 0,
statistics: {
totalNodes: result.statistics.totalNodes,
triggerNodes: result.statistics.triggerNodes,
validConnections: result.statistics.validConnections,
invalidConnections: result.statistics.invalidConnections
}
};
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_1.logger.error('Error validating workflow connections:', error);
return {
valid: false,
error: error instanceof Error ? error.message : 'Unknown error validating connections'
};
}
}
async validateWorkflowExpressions(workflow) {
await this.ensureInitialized();
if (!this.repository)
throw new Error('Repository not initialized');
const validator = new workflow_validator_1.WorkflowValidator(this.repository, enhanced_config_validator_1.EnhancedConfigValidator);
try {
const result = await validator.validateWorkflow(workflow, {
validateNodes: false,
validateConnections: false,
validateExpressions: true
});
const response = {
valid: result.errors.length === 0,
statistics: {
totalNodes: result.statistics.totalNodes,
expressionsValidated: result.statistics.expressionsValidated
}
};
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
}));
}
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_1.logger.error('Error validating workflow expressions:', error);
return {
valid: false,
error: error instanceof Error ? error.message : 'Unknown error validating expressions'
};
}
}
async run() {
await this.ensureInitialized();
const transport = new stdio_js_1.StdioServerTransport();
await this.server.connect(transport);
if (!process.stdout.isTTY || process.env.IS_DOCKER) {
const originalWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = function (chunk, encoding, callback) {
const result = originalWrite(chunk, encoding, callback);
process.stdout.emit('drain');
return result;
};
}
logger_1.logger.info('n8n Documentation MCP Server running on stdio transport');
process.stdin.resume();
}
async shutdown() {
logger_1.logger.info('Shutting down MCP server...');
if (this.cache) {
try {
this.cache.destroy();
logger_1.logger.info('Cache timers cleaned up');
}
catch (error) {
logger_1.logger.error('Error cleaning up cache:', error);
}
}
if (this.db) {
try {
await this.db.close();
logger_1.logger.info('Database connection closed');
}
catch (error) {
logger_1.logger.error('Error closing database:', error);
}
}
}
}
exports.N8NDocumentationMCPServer = N8NDocumentationMCPServer;
//# sourceMappingURL=server.js.map