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