Skip to main content
Glama

n8n-workflow-builder-mcp

by ifmelate
index.js146 kB
#!/usr/bin/env node "use strict"; // N8N Workflow Builder MCP Server // Using the official MCP SDK as required var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js"); const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js"); const zod_1 = require("zod"); const promises_1 = __importDefault(require("fs/promises")); const path_1 = __importDefault(require("path")); const workflowValidator_1 = require("./validation/workflowValidator"); const nodeTypesLoader_1 = require("./validation/nodeTypesLoader"); const workspace_1 = require("./utils/workspace"); const id_1 = require("./utils/id"); const llm_1 = require("./utils/llm"); const versioning_1 = require("./nodes/versioning"); const cache_1 = require("./nodes/cache"); // Log initial workspace console.error(`[DEBUG] Default workspace directory: ${(0, workspace_1.getWorkspaceDir)()}`); // Initialize supported N8N versions and their node capabilities // Remove in-file type/interface/utility definitions in favor of imports // Find the best matching version for a target N8N version // Returns exact match if available, otherwise closest lower version function findBestMatchingVersion(targetVersion, availableVersions) { if (availableVersions.length === 0) { return null; } // Check for exact match first if (availableVersions.includes(targetVersion)) { return targetVersion; } // Parse version numbers for comparison const parseVersion = (version) => { const parts = version.split('.').map(part => parseInt(part, 10) || 0); // Ensure we have at least 3 parts (major.minor.patch) while (parts.length < 3) { parts.push(0); } return parts; }; const targetParts = parseVersion(targetVersion); // Find all versions that are less than or equal to target version const candidateVersions = availableVersions.filter(version => { const versionParts = parseVersion(version); // Compare version parts (major.minor.patch) for (let i = 0; i < Math.max(targetParts.length, versionParts.length); i++) { const targetPart = targetParts[i] || 0; const versionPart = versionParts[i] || 0; if (versionPart < targetPart) { return true; // This version is lower } else if (versionPart > targetPart) { return false; // This version is higher } // If equal, continue to next part } return true; // Versions are equal (this shouldn't happen since we checked exact match above) }); if (candidateVersions.length === 0) { return null; // No suitable lower version found } // Sort candidates in descending order and return the highest (closest to target) candidateVersions.sort((a, b) => { const aParts = parseVersion(a); const bParts = parseVersion(b); for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { const aPart = aParts[i] || 0; const bPart = bParts[i] || 0; if (aPart !== bPart) { return bPart - aPart; // Descending order } } return 0; }); return candidateVersions[0]; } let nodeInfoCache = new Map(); // normalizeLLMParameters imported from utils/llm // loadKnownNodeBaseTypes handled in nodes/cache // Helper function to load nodes from a specific directory // loadNodesFromDirectory handled in nodes/cache // Helper function to normalize node types (OLD - to be replaced) // function normalizeNodeType(inputType: string): string { ... } // OLD FUNCTION // New function to get normalized type and version // normalizeNodeTypeAndVersion handled in nodes/cache // Helper function to resolve paths against workspace // Always treat paths as relative to WORKSPACE_DIR by stripping leading slashes // resolvePath handled in utils/workspace // Helper function to resolve workflow file path with optional direct path // resolveWorkflowPath handled in utils/workspace // Helper function to ensure the parent directory exists for a workflow file path // ensureWorkflowParentDir handled in utils/workspace // ID Generation Helpers // id helpers handled in utils/id // Constants // constants handled in utils/workspace // Helper functions // ensureWorkflowDir handled in utils/workspace async function loadWorkflows() { // This function will need to be updated if list_workflows is to work with the new format. // For now, it's related to the old format. const resolvedFile = (0, workspace_1.resolvePath)(path_1.default.join(workspace_1.WORKFLOW_DATA_DIR_NAME, workspace_1.WORKFLOWS_FILE_NAME)); try { await (0, workspace_1.ensureWorkflowDir)(); // Ensures dir exists, doesn't create workflows.json anymore unless called by old logic const data = await promises_1.default.readFile(resolvedFile, 'utf8'); console.error("[DEBUG] Loaded workflows (old format):", data); return JSON.parse(data); } catch (error) { if (error.code === 'ENOENT') { console.error("[DEBUG] No workflows.json file found (old format), returning empty array"); // If workflows.json is truly deprecated, this might try to create it. // For now, let's assume ensureWorkflowDir handles directory creation. // And if the file doesn't exist, it means no workflows (in old format). await promises_1.default.writeFile(resolvedFile, JSON.stringify([], null, 2)); // Create if not exists for old logic return []; } console.error('[ERROR] Failed to load workflows (old format):', error); throw error; } } async function saveWorkflows(workflows) { // This function is for the old format (saving an array to workflows.json). try { await (0, workspace_1.ensureWorkflowDir)(); const resolvedFile = (0, workspace_1.resolvePath)(path_1.default.join(workspace_1.WORKFLOW_DATA_DIR_NAME, workspace_1.WORKFLOWS_FILE_NAME)); console.error("[DEBUG] Saving workflows (old format):", JSON.stringify(workflows, null, 2)); await promises_1.default.writeFile(resolvedFile, JSON.stringify(workflows, null, 2)); } catch (error) { console.error('[ERROR] Failed to save workflows (old format):', error); throw error; } } // Create the MCP server const server = new mcp_js_1.McpServer({ name: "n8n-workflow-builder", version: "1.0.0" }); // Tool definitions // Create Workflow const createWorkflowParamsSchema = zod_1.z.object({ workflow_name: zod_1.z.string().describe("The name for the new workflow"), workspace_dir: zod_1.z.string().describe("Absolute path to the project root directory where workflow_data will be stored") }); server.tool("create_workflow", "Create a new n8n workflow", createWorkflowParamsSchema.shape, async (params, _extra) => { console.error("[DEBUG] create_workflow called with params:", params); const workflowName = params.workflow_name; const workspaceDir = params.workspace_dir; if (!workflowName || workflowName.trim() === "") { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "Parameter 'workflow_name' is required." }) }] }; } if (!workspaceDir || workspaceDir.trim() === "") { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "Parameter 'workspace_dir' is required." }) }] }; } try { const stat = await promises_1.default.stat(workspaceDir); if (!stat.isDirectory()) { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "Provided 'workspace_dir' is not a directory." }) }] }; } // Check if the workspaceDir is the root directory if (path_1.default.resolve(workspaceDir) === path_1.default.resolve('/')) { console.error("[ERROR] 'workspace_dir' cannot be the root directory ('/'). Please specify a valid project subdirectory."); return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "'workspace_dir' cannot be the root directory. Please specify a project subdirectory." }) }] }; } (0, workspace_1.setWorkspaceDir)(workspaceDir); await (0, workspace_1.ensureWorkflowDir)(); // Ensures WORKFLOW_DATA_DIR_NAME exists const newN8nWorkflow = { name: workflowName, id: (0, id_1.generateN8nId)(), // e.g., "Y6sBMxxyJQtgCCBQ" nodes: [], // Initialize with empty nodes array connections: {}, // Initialize with empty connections object active: false, pinData: {}, settings: { executionOrder: "v1" }, versionId: (0, id_1.generateUUID)(), meta: { instanceId: (0, id_1.generateInstanceId)() }, tags: [] }; // Sanitize workflowName for filename or ensure it's safe. // For now, using directly. Consider a sanitization function for production. const filename = `${workflowName.replace(/[^a-z0-9_.-]/gi, '_')}.json`; const filePath = (0, workspace_1.resolvePath)(path_1.default.join(workspace_1.WORKFLOW_DATA_DIR_NAME, filename)); await promises_1.default.writeFile(filePath, JSON.stringify(newN8nWorkflow, null, 2)); console.error("[DEBUG] Workflow created and saved to:", filePath); return { content: [{ type: "text", text: JSON.stringify({ success: true, workflow: newN8nWorkflow, recommended_next_step: "Call 'list_available_nodes' before adding nodes. Use 'search_term' (e.g., 'langchain') to find AI nodes." }) }] }; } catch (error) { console.error("[ERROR] Failed to create workflow:", error); return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "Failed to create workflow: " + error.message }) }] }; } }); // List Workflows // NOTE: This tool reads individual .json files from the workflow_data directory. const listWorkflowsParamsSchema = zod_1.z.object({ limit: zod_1.z.number().int().positive().max(1000).optional().describe("Maximum number of workflows to return"), cursor: zod_1.z.string().optional().describe("Opaque cursor for pagination; pass back to get the next page") }); server.tool("list_workflows", "List workflows in the workspace", listWorkflowsParamsSchema.shape, async (params, _extra) => { console.error("[DEBUG] list_workflows called - (current impl uses old format and might be broken)"); try { // This implementation needs to change to scan directory for .json files // and aggregate them. For now, it will likely fail or return empty // if workflows.json doesn't exist or is empty. await (0, workspace_1.ensureWorkflowDir)(); // Ensures directory exists const workflowDataDir = (0, workspace_1.resolvePath)(workspace_1.WORKFLOW_DATA_DIR_NAME); const files = await promises_1.default.readdir(workflowDataDir); const workflowFiles = files.filter(file => file.endsWith('.json') && file !== workspace_1.WORKFLOWS_FILE_NAME); const workflows = []; for (const file of workflowFiles) { try { const data = await promises_1.default.readFile(path_1.default.join(workflowDataDir, file), 'utf8'); workflows.push(JSON.parse(data)); } catch (err) { console.error(`[ERROR] Failed to read or parse workflow file ${file}:`, err); // Decide how to handle: skip, error out, etc. } } console.error(`[DEBUG] Retrieved ${workflows.length} workflows from individual files.`); // Apply simple offset cursor pagination const startIndex = params?.cursor ? Number(params.cursor) || 0 : 0; const limit = params?.limit ?? workflows.length; const page = workflows.slice(startIndex, startIndex + limit); const nextIndex = startIndex + limit; const nextCursor = nextIndex < workflows.length ? String(nextIndex) : null; return { content: [{ type: "text", text: JSON.stringify({ success: true, workflows: page, nextCursor, total: workflows.length }) }] }; } catch (error) { console.error("[ERROR] Failed to list workflows:", error); return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "Failed to list workflows: " + error.message }) }] }; } }); // Get Workflow Details // NOTE: This tool will need to be updated. It currently assumes workflow_id is // an ID found in the old workflows.json structure. It should now probably // expect workflow_id to be the workflow name (to form the filename) or the new N8n ID. const getWorkflowDetailsParamsSchema = zod_1.z.object({ workflow_name: zod_1.z.string().describe("The Name of the workflow to get details for"), workflow_path: zod_1.z.string().optional().describe("Optional direct path to the workflow file (absolute or relative to current working directory). If not provided, uses standard workflow_data directory approach.") }); server.tool("get_workflow_details", "Get workflow details by name or path", getWorkflowDetailsParamsSchema.shape, async (params, _extra) => { const workflowName = params.workflow_name; console.error("[DEBUG] get_workflow_details called with name:", workflowName); try { let filePath = (0, workspace_1.resolveWorkflowPath)(workflowName, params.workflow_path); // Auto-detect workspace when only workflow_name is provided and default path doesn't exist try { if (!params.workflow_path) { await promises_1.default.access(filePath).catch(async () => { const detected = await (0, workspace_1.tryDetectWorkspaceForName)(workflowName); if (detected) filePath = detected; }); } } catch { } // Ensure parent directory only when explicit path provided if (params.workflow_path) { await (0, workspace_1.ensureWorkflowParentDir)(filePath); } try { const data = await promises_1.default.readFile(filePath, 'utf8'); const workflow = JSON.parse(data); console.error("[DEBUG] Found workflow by name in file:", filePath); return { content: [{ type: "text", text: JSON.stringify({ success: true, workflow }) }] }; } catch (error) { if (error.code === 'ENOENT') { console.warn(`[DEBUG] Workflow file ${filePath} not found using name: ${workflowName}.`); return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Workflow with name ${workflowName} not found` }) }] }; } else { throw error; // Re-throw other read errors } } } catch (error) { console.error("[ERROR] Failed to get workflow details:", error); return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "Failed to get workflow details: " + error.message }) }] }; } }); // Add Node // NOTE: This tool will need significant updates to load the specific workflow file, // add the node to its 'nodes' array, and save the file. const addNodeParamsSchema = zod_1.z.object({ workflow_name: zod_1.z.string().describe("The Name of the workflow to add the node to"), node_type: zod_1.z.string().describe("The type of node to add (e.g., 'gmail', 'slack', 'openAi'). You can specify with or without the 'n8n-nodes-base.' prefix. The system will handle proper casing (e.g., 'openai' will be converted to 'openAi' if that's the correct casing)."), position: zod_1.z.object({ x: zod_1.z.number(), y: zod_1.z.number() }).optional().describe("The position of the node {x,y} - will be converted to [x,y] for N8nWorkflowNode"), parameters: zod_1.z.record(zod_1.z.string(), zod_1.z.any()).optional().describe("The parameters for the node"), node_name: zod_1.z.string().optional().describe("The name for the new node (e.g., 'My Gmail Node')"), typeVersion: zod_1.z.number().optional().describe("The type version for the node (e.g., 1, 1.1). Defaults to 1 if not specified."), webhookId: zod_1.z.string().optional().describe("Optional webhook ID for certain node types like triggers."), workflow_path: zod_1.z.string().optional().describe("Optional direct path to the workflow file (absolute or relative to current working directory). If not provided, uses standard workflow_data directory approach."), connect_from: zod_1.z.array(zod_1.z.object({ source_node_id: zod_1.z.string().describe("Existing node ID to connect FROM"), source_node_output_name: zod_1.z.string().describe("Output handle on the source node (e.g., 'main' or 'ai_tool')"), target_node_input_name: zod_1.z.string().default('main').describe("Input handle on the new node"), target_node_input_index: zod_1.z.number().optional().default(0).describe("Input index on the new node (default: 0)") })).optional().describe("Optional: create connections from existing nodes to this new node"), connect_to: zod_1.z.array(zod_1.z.object({ target_node_id: zod_1.z.string().describe("Existing node ID to connect TO"), source_node_output_name: zod_1.z.string().describe("Output handle on the new node (e.g., 'main' or 'ai_languageModel')"), target_node_input_name: zod_1.z.string().default('main').describe("Input handle on the target node"), target_node_input_index: zod_1.z.number().optional().default(0).describe("Input index on the target node (default: 0)") })).optional().describe("Optional: create connections from this new node to existing nodes") }); server.tool("add_node", "Add a node to an n8n workflow", addNodeParamsSchema.shape, async (params, _extra) => { console.error("[DEBUG] add_node called with:", params); const workflowName = params.workflow_name; try { // Attempt to reload node types if cache is empty and workspace changed if ((0, cache_1.getNodeInfoCache)().size === 0 && (0, workspace_1.getWorkspaceDir)() !== process.cwd()) { console.warn("[WARN] nodeInfoCache is empty in add_node. Attempting to reload based on current WORKSPACE_DIR."); await (0, cache_1.loadKnownNodeBaseTypes)(); } let filePath = (0, workspace_1.resolveWorkflowPath)(workflowName, params.workflow_path); // If only workflow_name is provided and default path doesn't exist, try to detect workspace try { if (!params.workflow_path) { await promises_1.default.access(filePath).catch(async () => { const detected = await (0, workspace_1.tryDetectWorkspaceForName)(workflowName); if (detected) filePath = detected; }); } else { await (0, workspace_1.ensureWorkflowParentDir)(filePath); } } catch { } let workflow; try { const data = await promises_1.default.readFile(filePath, 'utf8'); workflow = JSON.parse(data); } catch (readError) { if (readError.code === 'ENOENT') { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Workflow with name ${workflowName} not found at ${filePath}` }) }] }; } throw readError; } // Ensure workflow.nodes exists if (!Array.isArray(workflow.nodes)) { workflow.nodes = []; } const defaultPos = params.position || { x: Math.floor(Math.random() * 500), y: Math.floor(Math.random() * 500) }; // Normalize the node type and resolve to a compatible typeVersion automatically const { finalNodeType, finalTypeVersion } = (0, cache_1.normalizeNodeTypeAndVersion)(params.node_type, params.typeVersion); let resolvedVersion = finalTypeVersion; // Check if node type is supported in current N8N version if (!(0, cache_1.isNodeTypeSupported)(finalNodeType, finalTypeVersion)) { // Auto-heal: if the chosen version is not supported, try to downgrade to the highest supported one const supported = (0, versioning_1.getN8nVersionInfo)()?.supportedNodes.get(finalNodeType); if (supported && supported.size > 0) { const sorted = Array.from(supported).map(v => Number(v)).filter(v => !isNaN(v)).sort((a, b) => b - a); const fallback = sorted[0]; if (fallback !== undefined) { console.warn(`[WARN] Requested ${finalNodeType}@${finalTypeVersion} not supported on ${(0, versioning_1.getCurrentN8nVersion)()}; falling back to ${fallback}`); resolvedVersion = fallback; } } else { const supportedVersionsList = supported ? Array.from(supported).join(', ') : 'none'; return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Node type '${finalNodeType}' version ${finalTypeVersion} is not supported in N8N version ${(0, versioning_1.getCurrentN8nVersion)()}. Supported versions: ${supportedVersionsList}. Check 'list_available_nodes' for compatible alternatives or set N8N_VERSION environment variable.` }) }] }; } } // Process parameters for LangChain LLM nodes let nodeParameters = params.parameters || {}; // Prepare optional node-level credentials placeholder map let nodeCredentials = {}; // Check if this is a LangChain LLM node const isLangChainLLM = finalNodeType.includes('@n8n/n8n-nodes-langchain') && (finalNodeType.includes('lmChat') || finalNodeType.includes('llm')); // Apply normalization for LangChain LLM nodes if (isLangChainLLM) { console.error(`[DEBUG] Applying parameter normalization for LangChain LLM node`); nodeParameters = (0, llm_1.normalizeLLMParameters)(nodeParameters); } else { // Handle OpenAI credentials specifically for non-LangChain nodes if (params.parameters?.options?.credentials?.providerType === 'openAi') { console.error(`[DEBUG] Setting up proper OpenAI credentials format for standard node`); // Remove credentials from options and set at node level if (nodeParameters.options?.credentials) { const credentialsType = nodeParameters.options.credentials.providerType; delete nodeParameters.options.credentials; // Set a placeholder for credentials that would be filled in the n8n UI if (!nodeParameters.credentials) { nodeParameters.credentials = {}; } // Add credentials in the proper format for OpenAI (also reflect at node-level for validator) const credId = (0, id_1.generateN8nId)(); nodeParameters.credentials = { "openAiApi": { "id": credId, "name": "OpenAi account" } }; nodeCredentials["openAiApi"] = { id: credId, name: "OpenAi account" }; } } } // Generic credential placeholder injection based on node definition (to pass validator pre-checks) try { const nodeTypes = await (0, nodeTypesLoader_1.loadNodeTypesForCurrentVersion)(path_1.default.resolve(__dirname, '../workflow_nodes'), (0, versioning_1.getCurrentN8nVersion)()); const nodeTypeDef = nodeTypes.getByNameAndVersion(finalNodeType, resolvedVersion); const credsCfg = nodeTypeDef?.description?.credentialsConfig; if (Array.isArray(credsCfg)) { for (const cfg of credsCfg) { if (cfg?.required) { const key = String(cfg.name); if (!nodeCredentials[key]) { nodeCredentials[key] = { id: (0, id_1.generateN8nId)(), name: `${key}-placeholder` }; } } } } } catch (e) { console.warn('[WARN] Could not compute credential placeholders for node:', e?.message || e); } const newNode = { id: (0, id_1.generateUUID)(), type: finalNodeType, typeVersion: resolvedVersion, // Use version from normalizeNodeTypeAndVersion (or auto-healed) position: [defaultPos.x, defaultPos.y], parameters: nodeParameters, name: params.node_name || `${finalNodeType} Node`, // Use finalNodeType for default name ...(params.webhookId && { webhookId: params.webhookId }) // Add webhookId if provided }; if (Object.keys(nodeCredentials).length > 0) { newNode.credentials = nodeCredentials; } // Pre-validate before persisting try { const nodeTypes = await (0, nodeTypesLoader_1.loadNodeTypesForCurrentVersion)(path_1.default.resolve(__dirname, '../workflow_nodes'), (0, versioning_1.getCurrentN8nVersion)()); const tentative = { ...workflow, nodes: [...workflow.nodes, newNode] }; const preReport = (0, workflowValidator_1.validateAndNormalizeWorkflow)(tentative, nodeTypes); // Filter out credential issues from blocking validation (for add operations) const hasBlockingIssues = preReport.nodeIssues && Object.values(preReport.nodeIssues).some((arr) => Array.isArray(arr) && arr.some((issue) => issue.code && issue.code !== 'missing_credentials')); // Only fail validation if there are actual errors OR non-credential blocking issues if (!preReport.ok || (preReport.errors && preReport.errors.length > 0) || hasBlockingIssues) { console.warn('[WARN] Blocking validation on add_node:', { errors: preReport.errors, nodeIssues: preReport.nodeIssues }); // Sanitize validation payload to avoid leaking transient node IDs when creation fails const idsToRedact = [newNode.id]; const redactText = (text) => { if (typeof text !== 'string') return text; let out = text; for (const id of idsToRedact) { if (id && typeof id === 'string') { out = out.split(id).join('[redacted]'); } } return out; }; const sanitizeWarnings = (warnings) => { if (!Array.isArray(warnings)) return warnings; return warnings.map((w) => { const copy = { ...w }; if (copy.message) copy.message = redactText(copy.message); if (copy.details && typeof copy.details === 'object') { copy.details = { ...copy.details }; if (idsToRedact.includes(copy.details.nodeId)) delete copy.details.nodeId; } return copy; }); }; const sanitizeErrors = (errors) => { if (!Array.isArray(errors)) return errors; return errors.map((e) => { if (typeof e === 'string') return redactText(e); if (e && typeof e === 'object') { const ec = { ...e }; if (ec.message) ec.message = redactText(ec.message); return ec; } return e; }); }; const sanitizeNodeIssues = (nodeIssues) => { if (!nodeIssues || typeof nodeIssues !== 'object') return nodeIssues; const out = {}; for (const [nodeName, issues] of Object.entries(nodeIssues)) { out[nodeName] = Array.isArray(issues) ? issues.map((iss) => { const ic = { ...iss }; if (ic.message) ic.message = redactText(ic.message); return ic; }) : issues; } return out; }; const sanitizedValidation = { ok: preReport.ok, errors: sanitizeErrors(preReport.errors), warnings: sanitizeWarnings(preReport.warnings), nodeIssues: sanitizeNodeIssues(preReport.nodeIssues) }; return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "Validation failed for node creation", validation: sanitizedValidation }) }] }; } } catch (e) { console.warn('[WARN] Pre-write validation step errored in add_node:', e?.message || e); } workflow.nodes.push(newNode); await promises_1.default.writeFile(filePath, JSON.stringify(workflow, null, 2)); console.error(`[DEBUG] Added node ${newNode.id} to workflow ${workflowName} in file ${filePath}`); // Optional wiring after add const createdConnections = []; try { if (params.connect_from && params.connect_from.length > 0) { for (const instr of params.connect_from) { const sourceNode = workflow.nodes.find(n => n.id === instr.source_node_id); const targetNode = newNode; if (!sourceNode) { console.warn(`[WARN] connect_from source not found: ${instr.source_node_id}`); continue; } const sourceNameKey = sourceNode.name; const outName = instr.source_node_output_name; const inName = instr.target_node_input_name || 'main'; const inIndex = instr.target_node_input_index ?? 0; if (!workflow.connections) workflow.connections = {}; if (!workflow.connections[sourceNameKey]) workflow.connections[sourceNameKey] = {}; if (!workflow.connections[sourceNameKey][outName]) workflow.connections[sourceNameKey][outName] = []; workflow.connections[sourceNameKey][outName].push([{ node: targetNode.name, type: inName, index: inIndex }]); createdConnections.push({ from: `${sourceNode.name} (${sourceNode.id})`, fromOutput: outName, to: `${targetNode.name} (${targetNode.id})`, toInput: inName, index: inIndex }); } } if (params.connect_to && params.connect_to.length > 0) { for (const instr of params.connect_to) { const sourceNode = newNode; const targetNode = workflow.nodes.find(n => n.id === instr.target_node_id); if (!targetNode) { console.warn(`[WARN] connect_to target not found: ${instr.target_node_id}`); continue; } const sourceNameKey = sourceNode.name; const outName = instr.source_node_output_name; const inName = instr.target_node_input_name || 'main'; const inIndex = instr.target_node_input_index ?? 0; if (!workflow.connections) workflow.connections = {}; if (!workflow.connections[sourceNameKey]) workflow.connections[sourceNameKey] = {}; if (!workflow.connections[sourceNameKey][outName]) workflow.connections[sourceNameKey][outName] = []; workflow.connections[sourceNameKey][outName].push([{ node: targetNode.name, type: inName, index: inIndex }]); createdConnections.push({ from: `${sourceNode.name} (${sourceNode.id})`, fromOutput: outName, to: `${targetNode.name} (${targetNode.id})`, toInput: inName, index: inIndex }); } } if (createdConnections.length > 0) { await promises_1.default.writeFile(filePath, JSON.stringify(workflow, null, 2)); console.error(`[DEBUG] add_node created ${createdConnections.length} connection(s) as requested`); } } catch (e) { console.warn('[WARN] Optional wiring in add_node failed:', e?.message || e); } // Validate after modification try { const nodeTypes = await (0, nodeTypesLoader_1.loadNodeTypesForCurrentVersion)(path_1.default.resolve(__dirname, '../workflow_nodes'), (0, versioning_1.getCurrentN8nVersion)()); const report = (0, workflowValidator_1.validateAndNormalizeWorkflow)(workflow, nodeTypes); if (!report.ok || (report.warnings && report.warnings.length > 0)) { if (!report.ok) console.warn('[WARN] Workflow validation failed after add_node', report.errors); return { content: [{ type: "text", text: JSON.stringify({ success: true, node: newNode, workflowId: workflow.id, createdConnections, validation: { ok: report.ok, errors: report.errors, warnings: report.warnings, nodeIssues: report.nodeIssues } }) }] }; } } catch (e) { console.warn('[WARN] Validation step errored after add_node:', e?.message || e); } return { content: [{ type: "text", text: JSON.stringify({ success: true, node: newNode, workflowId: workflow.id, createdConnections }) }] }; } catch (error) { console.error("[ERROR] Failed to add node:", error); return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "Failed to add node: " + error.message }) }] }; } }); // Edit Node // NOTE: This tool also needs updates for single-file workflow management. const editNodeParamsSchema = zod_1.z.object({ workflow_name: zod_1.z.string().describe("The Name of the workflow containing the node"), node_id: zod_1.z.string().describe("The ID of the node to edit"), node_type: zod_1.z.string().optional().describe("The new type for the node (e.g., 'gmail', 'slack', 'openAi'). You can specify with or without the 'n8n-nodes-base.' prefix. The system will handle proper casing (e.g., 'openai' will be converted to 'openAi' if that's the correct casing)."), node_name: zod_1.z.string().optional().describe("The new name for the node"), position: zod_1.z.object({ x: zod_1.z.number(), y: zod_1.z.number() }).optional().describe("The new position {x,y} - will be converted to [x,y]"), parameters: zod_1.z.record(zod_1.z.string(), zod_1.z.any()).optional().describe("The new parameters"), typeVersion: zod_1.z.number().optional().describe("The new type version for the node"), webhookId: zod_1.z.string().optional().describe("Optional new webhook ID for the node."), workflow_path: zod_1.z.string().optional().describe("Optional workflow path to the workflow file"), connect_from: zod_1.z.array(zod_1.z.object({ source_node_id: zod_1.z.string().describe("Existing node ID to connect FROM"), source_node_output_name: zod_1.z.string().describe("Output handle on the source node (e.g., 'main' or 'ai_tool')"), target_node_input_name: zod_1.z.string().default('main').describe("Input handle on this node"), target_node_input_index: zod_1.z.number().optional().default(0).describe("Input index on this node (default: 0)") })).optional().describe("Optional: create connections from existing nodes to this node after edit"), connect_to: zod_1.z.array(zod_1.z.object({ target_node_id: zod_1.z.string().describe("Existing node ID to connect TO"), source_node_output_name: zod_1.z.string().describe("Output handle on this node (e.g., 'main' or 'ai_languageModel')"), target_node_input_name: zod_1.z.string().default('main').describe("Input handle on the target node"), target_node_input_index: zod_1.z.number().optional().default(0).describe("Input index on the target node (default: 0)") })).optional().describe("Optional: create connections from this node to existing nodes after edit") }); server.tool("edit_node", "Edit an existing node in a workflow", editNodeParamsSchema.shape, async (params, _extra) => { console.error("[DEBUG] edit_node called with:", params); const workflowName = params.workflow_name; try { // Similar cache reload logic as in add_node if ((0, cache_1.getNodeInfoCache)().size === 0 && (0, workspace_1.getWorkspaceDir)() !== process.cwd()) { console.warn("[WARN] nodeInfoCache is empty in edit_node. Attempting to reload based on current WORKSPACE_DIR."); await (0, cache_1.loadKnownNodeBaseTypes)(); } const filePath = (0, workspace_1.resolveWorkflowPath)(workflowName, params.workflow_path); // Only ensure the default workflow directory if using standard approach if (!params.workflow_path) { await (0, workspace_1.ensureWorkflowDir)(); } else { // Ensure the parent directory of the custom workflow file exists await (0, workspace_1.ensureWorkflowParentDir)(filePath); } let workflow; try { const data = await promises_1.default.readFile(filePath, 'utf8'); workflow = JSON.parse(data); } catch (readError) { if (readError.code === 'ENOENT') { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Workflow with name ${workflowName} not found at ${filePath}` }) }] }; } throw readError; } const nodeIndex = workflow.nodes.findIndex(n => n.id === params.node_id); if (nodeIndex === -1) { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Node with id ${params.node_id} not found in workflow ${workflowName}` }) }] }; } const nodeToEdit = workflow.nodes[nodeIndex]; let newType = nodeToEdit.type; let newTypeVersion = nodeToEdit.typeVersion; if (params.node_type) { // If node_type is changing, typeVersion should be re-evaluated based on the new type, // unless a specific params.typeVersion is also given for this edit. const { finalNodeType, finalTypeVersion: determinedVersionForNewType } = (0, cache_1.normalizeNodeTypeAndVersion)(params.node_type, params.typeVersion); newType = finalNodeType; newTypeVersion = determinedVersionForNewType; // This uses params.typeVersion if valid, else default for new type. // Guard against incompatible version by selecting the highest supported for current n8n if (!(0, cache_1.isNodeTypeSupported)(newType, newTypeVersion)) { const supported = (0, versioning_1.getN8nVersionInfo)()?.supportedNodes.get(newType); if (supported && supported.size > 0) { const sorted = Array.from(supported).map(v => Number(v)).filter(v => !isNaN(v)).sort((a, b) => b - a); if (sorted.length > 0) newTypeVersion = sorted[0]; } } } else if (params.typeVersion !== undefined && !isNaN(Number(params.typeVersion))) { // Only typeVersion is being changed, node_type remains the same. newTypeVersion = Number(params.typeVersion); } else if (params.typeVersion !== undefined && isNaN(Number(params.typeVersion))) { console.warn(`[WARN] Provided typeVersion '${params.typeVersion}' for editing node ${nodeToEdit.id} is NaN. typeVersion will not be changed.`); } nodeToEdit.type = newType; nodeToEdit.typeVersion = newTypeVersion; if (params.node_name) nodeToEdit.name = params.node_name; if (params.position) nodeToEdit.position = [params.position.x, params.position.y]; // Process new parameters if provided if (params.parameters) { let newParameters = params.parameters; // Check if this is a LangChain LLM node const isLangChainLLM = newType.includes('@n8n/n8n-nodes-langchain') && (newType.includes('lmChat') || newType.includes('llm')); // Apply normalization for LangChain LLM nodes if (isLangChainLLM) { console.error(`[DEBUG] Applying parameter normalization for LangChain LLM node during edit`); newParameters = (0, llm_1.normalizeLLMParameters)(newParameters); } else { // Handle OpenAI credentials specifically for non-LangChain nodes if (newParameters.options?.credentials?.providerType === 'openAi') { console.error(`[DEBUG] Setting up proper OpenAI credentials format for standard node during edit`); // Remove credentials from options and set at node level if (newParameters.options?.credentials) { const credentialsType = newParameters.options.credentials.providerType; delete newParameters.options.credentials; // Set a placeholder for credentials that would be filled in the n8n UI if (!newParameters.credentials) { newParameters.credentials = {}; } // Add credentials in the proper format for OpenAI newParameters.credentials = { "openAiApi": { "id": (0, id_1.generateN8nId)(), "name": "OpenAi account" } }; } } } nodeToEdit.parameters = newParameters; } if (params.webhookId !== undefined) { // Allow setting or unsetting webhookId if (params.webhookId === null || params.webhookId === "") { // Check for explicit clear delete nodeToEdit.webhookId; } else { nodeToEdit.webhookId = params.webhookId; } } // Inject generic credential placeholders for required credentials if missing try { const nodeTypes = await (0, nodeTypesLoader_1.loadNodeTypesForCurrentVersion)(path_1.default.resolve(__dirname, '../workflow_nodes'), (0, versioning_1.getCurrentN8nVersion)()); const typeDef = nodeTypes.getByNameAndVersion(nodeToEdit.type, nodeToEdit.typeVersion); const credsCfg = typeDef?.description?.credentialsConfig; if (Array.isArray(credsCfg) && credsCfg.length > 0) { nodeToEdit.credentials = nodeToEdit.credentials || {}; for (const cfg of credsCfg) { if (cfg?.required) { const key = String(cfg.name); if (!nodeToEdit.credentials[key]) { nodeToEdit.credentials[key] = { id: (0, id_1.generateN8nId)(), name: `${key}-placeholder` }; } } } } } catch (e) { console.warn('[WARN] Could not inject credential placeholders during edit_node:', e?.message || e); } // Pre-validate before persisting try { const nodeTypes = await (0, nodeTypesLoader_1.loadNodeTypesForCurrentVersion)(path_1.default.resolve(__dirname, '../workflow_nodes'), (0, versioning_1.getCurrentN8nVersion)()); const tentative = { ...workflow, nodes: workflow.nodes.map((n, i) => i === nodeIndex ? nodeToEdit : n) }; const preReport = (0, workflowValidator_1.validateAndNormalizeWorkflow)(tentative, nodeTypes); // Filter out credential issues from blocking validation (for edit operations) const hasBlockingIssues = preReport.nodeIssues && Object.values(preReport.nodeIssues).some((arr) => Array.isArray(arr) && arr.some((issue) => issue.code && issue.code !== 'missing_credentials')); // Only fail validation if there are actual errors OR non-credential blocking issues if (!preReport.ok || (preReport.errors && preReport.errors.length > 0) || hasBlockingIssues) { console.warn('[WARN] Blocking validation on edit_node:', { errors: preReport.errors, nodeIssues: preReport.nodeIssues }); return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "Validation failed for node edit", validation: { ok: preReport.ok, errors: preReport.errors, warnings: preReport.warnings, nodeIssues: preReport.nodeIssues } }) }] }; } } catch (e) { console.warn('[WARN] Pre-write validation step errored in edit_node:', e?.message || e); } workflow.nodes[nodeIndex] = nodeToEdit; await promises_1.default.writeFile(filePath, JSON.stringify(workflow, null, 2)); console.error(`[DEBUG] Edited node ${params.node_id} in workflow ${workflowName} in file ${filePath}`); // Optional wiring after edit const createdConnections = []; try { if (params.connect_from && params.connect_from.length > 0) { for (const instr of params.connect_from) { const sourceNode = workflow.nodes.find(n => n.id === instr.source_node_id); const targetNode = nodeToEdit; if (!sourceNode) { console.warn(`[WARN] connect_from source not found: ${instr.source_node_id}`); continue; } const sourceNameKey = sourceNode.name; const outName = instr.source_node_output_name; const inName = instr.target_node_input_name || 'main'; const inIndex = instr.target_node_input_index ?? 0; if (!workflow.connections) workflow.connections = {}; if (!workflow.connections[sourceNameKey]) workflow.connections[sourceNameKey] = {}; if (!workflow.connections[sourceNameKey][outName]) workflow.connections[sourceNameKey][outName] = []; workflow.connections[sourceNameKey][outName].push([{ node: targetNode.name, type: inName, index: inIndex }]); createdConnections.push({ from: `${sourceNode.name} (${sourceNode.id})`, fromOutput: outName, to: `${targetNode.name} (${targetNode.id})`, toInput: inName, index: inIndex }); } } if (params.connect_to && params.connect_to.length > 0) { for (const instr of params.connect_to) { const sourceNode = nodeToEdit; const targetNode = workflow.nodes.find(n => n.id === instr.target_node_id); if (!targetNode) { console.warn(`[WARN] connect_to target not found: ${instr.target_node_id}`); continue; } const sourceNameKey = sourceNode.name; const outName = instr.source_node_output_name; const inName = instr.target_node_input_name || 'main'; const inIndex = instr.target_node_input_index ?? 0; if (!workflow.connections) workflow.connections = {}; if (!workflow.connections[sourceNameKey]) workflow.connections[sourceNameKey] = {}; if (!workflow.connections[sourceNameKey][outName]) workflow.connections[sourceNameKey][outName] = []; workflow.connections[sourceNameKey][outName].push([{ node: targetNode.name, type: inName, index: inIndex }]); createdConnections.push({ from: `${sourceNode.name} (${sourceNode.id})`, fromOutput: outName, to: `${targetNode.name} (${targetNode.id})`, toInput: inName, index: inIndex }); } } if (createdConnections.length > 0) { await promises_1.default.writeFile(filePath, JSON.stringify(workflow, null, 2)); console.error(`[DEBUG] edit_node created ${createdConnections.length} connection(s) as requested`); } } catch (e) { console.warn('[WARN] Optional wiring in edit_node failed:', e?.message || e); } // Validate after modification try { const nodeTypes = await (0, nodeTypesLoader_1.loadNodeTypesForCurrentVersion)(path_1.default.resolve(__dirname, '../workflow_nodes'), (0, versioning_1.getCurrentN8nVersion)()); const report = (0, workflowValidator_1.validateAndNormalizeWorkflow)(workflow, nodeTypes); if (!report.ok || (report.warnings && report.warnings.length > 0)) { if (!report.ok) console.warn('[WARN] Workflow validation failed after edit_node', report.errors); return { content: [{ type: "text", text: JSON.stringify({ success: true, node: nodeToEdit, createdConnections, validation: { ok: report.ok, errors: report.errors, warnings: report.warnings, nodeIssues: report.nodeIssues } }) }] }; } } catch (e) { console.warn('[WARN] Validation step errored after edit_node:', e?.message || e); } return { content: [{ type: "text", text: JSON.stringify({ success: true, node: nodeToEdit, createdConnections }) }] }; } catch (error) { console.error("[ERROR] Failed to edit node:", error); return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "Failed to edit node: " + error.message }) }] }; } }); // Delete Node // NOTE: This tool also needs updates for single-file workflow management. const deleteNodeParamsSchema = zod_1.z.object({ workflow_name: zod_1.z.string().describe("The Name of the workflow containing the node"), node_id: zod_1.z.string().describe("The ID of the node to delete"), workflow_path: zod_1.z.string().optional().describe("Optional direct path to the workflow file (absolute or relative to current working directory). If not provided, uses standard workflow_data directory approach.") }); server.tool("delete_node", "Delete a node from a workflow", deleteNodeParamsSchema.shape, async (params, _extra) => { console.error("[DEBUG] delete_node called with:", params); const workflowName = params.workflow_name; try { const filePath = (0, workspace_1.resolveWorkflowPath)(workflowName, params.workflow_path); // Only ensure the default workflow directory if using standard approach if (!params.workflow_path) { await (0, workspace_1.ensureWorkflowDir)(); } else { // Ensure the parent directory of the custom workflow file exists await (0, workspace_1.ensureWorkflowParentDir)(filePath); } let workflow; try { const data = await promises_1.default.readFile(filePath, 'utf8'); workflow = JSON.parse(data); } catch (readError) { if (readError.code === 'ENOENT') { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Workflow with name ${workflowName} not found at ${filePath}` }) }] }; } throw readError; } const nodeIndex = workflow.nodes.findIndex(n => n.id === params.node_id); if (nodeIndex === -1) { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Node with id ${params.node_id} not found in workflow ${workflowName}` }) }] }; } const deletedNodeName = workflow.nodes[nodeIndex].name; workflow.nodes.splice(nodeIndex, 1); // Also remove connections related to this node // This is a simplified connection removal. n8n's logic might be more complex. const newConnections = {}; for (const sourceNodeName in workflow.connections) { if (sourceNodeName === deletedNodeName) continue; // Skip connections FROM the deleted node const outputConnections = workflow.connections[sourceNodeName]; const newOutputConnectionsForSource = {}; for (const outputKey in outputConnections) { const connectionChains = outputConnections[outputKey]; const newConnectionChains = []; for (const chain of connectionChains) { const newChain = chain.filter(connDetail => connDetail.node !== deletedNodeName); if (newChain.length > 0) { newConnectionChains.push(newChain); } } if (newConnectionChains.length > 0) { newOutputConnectionsForSource[outputKey] = newConnectionChains; } } if (Object.keys(newOutputConnectionsForSource).length > 0) { newConnections[sourceNodeName] = newOutputConnectionsForSource; } } workflow.connections = newConnections; await promises_1.default.writeFile(filePath, JSON.stringify(workflow, null, 2)); console.error(`[DEBUG] Deleted node ${params.node_id} from workflow ${workflowName} in file ${filePath}`); return { content: [{ type: "text", text: JSON.stringify({ success: true, message: `Node ${params.node_id} deleted successfully from workflow ${workflowName}` }) }] }; } catch (error) { console.error("[ERROR] Failed to delete node:", error); return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "Failed to delete node: " + error.message }) }] }; } }); // Add Connection const addConnectionParamsSchema = zod_1.z.object({ workflow_name: zod_1.z.string().describe("The Name of the workflow to add the connection to"), source_node_id: zod_1.z.string().describe("The ID of the source node for the connection"), source_node_output_name: zod_1.z.string().describe("The name of the output handle on the source node (e.g., 'main')"), target_node_id: zod_1.z.string().describe("The ID of the target node for the connection"), target_node_input_name: zod_1.z.string().describe("The name of the input handle on the target node (e.g., 'main')"), target_node_input_index: zod_1.z.number().optional().default(0).describe("The index for the target node's input handle (default: 0)"), workflow_path: zod_1.z.string().optional().describe("Optional direct path to the workflow file (absolute or relative to current working directory). If not provided, uses standard workflow_data directory approach.") }); server.tool("add_connection", "Create a connection between two nodes", addConnectionParamsSchema.shape, async (params, _extra) => { console.error("[DEBUG] add_connection called with:", params); const { workflow_name, source_node_id, source_node_output_name, target_node_id, target_node_input_name, target_node_input_index } = params; try { let filePath = (0, workspace_1.resolveWorkflowPath)(workflow_name, params.workflow_path); try { if (!params.workflow_path) { await promises_1.default.access(filePath).catch(async () => { const detected = await (0, workspace_1.tryDetectWorkspaceForName)(workflow_name); if (detected) filePath = detected; }); } } catch { } let workflow; try { const data = await promises_1.default.readFile(filePath, 'utf8'); workflow = JSON.parse(data); } catch (readError) { if (readError.code === 'ENOENT') { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Workflow with name ${workflow_name} not found at ${filePath}` }) }] }; } throw readError; } const sourceNode = workflow.nodes.find(node => node.id === source_node_id); const targetNode = workflow.nodes.find(node => node.id === target_node_id); if (!sourceNode) { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Source node with ID ${source_node_id} not found in workflow ${workflow_name}` }) }] }; } if (!targetNode) { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Target node with ID ${target_node_id} not found in workflow ${workflow_name}` }) }] }; } const sourceNodeNameKey = sourceNode.name; // n8n connections are keyed by node *name* const targetNodeNameValue = targetNode.name; // Detect if we're working with LangChain AI nodes that require special connection handling const isLangChainSource = sourceNode.type.includes('@n8n/n8n-nodes-langchain'); const isLangChainTarget = targetNode.type.includes('@n8n/n8n-nodes-langchain'); const isAIConnection = source_node_output_name.startsWith('ai_') || target_node_input_name.startsWith('ai_'); let connectionDirection = "forward"; // Default: source -> target // Check if we need to reverse connection direction for AI nodes // This handles the special case for LangChain nodes where tools and models // connect TO the agent rather than the agent connecting to them if ((isLangChainSource || isLangChainTarget) && isAIConnection) { // Check if this might be a case where direction needs to be reversed // - Models/Tools point TO Agent (reversed) // - Agent points to regular nodes (forward) // - Triggers point to any node (forward) // - Memory nodes point TO Agent (reversed) if ( // If it's a LLM, Tool, or Memory node pointing to an agent (sourceNode.type.includes('lmChat') || sourceNode.type.includes('tool') || sourceNode.type.toLowerCase().includes('request') || sourceNode.type.includes('memory')) && targetNode.type.includes('agent')) { console.warn("[WARN] LangChain AI connection detected. N8n often expects models, tools, and memory to connect TO agents rather than agents connecting to them."); console.warn("[WARN] Connections will be created as specified, but if they don't appear correctly in n8n UI, try reversing the source and target."); // Special hint for memory connections if (sourceNode.type.includes('memory')) { if (source_node_output_name !== 'ai_memory') { console.warn("[WARN] Memory nodes should usually connect to agents using 'ai_memory' output, not '" + source_node_output_name + "'."); } if (target_node_input_name !== 'ai_memory') { console.warn("[WARN] Agents should receive memory connections on 'ai_memory' input, not '" + target_node_input_name + "'."); } } } } const newConnectionObject = { node: targetNodeNameValue, type: target_node_input_name, index: target_node_input_index }; if (!workflow.connections) { workflow.connections = {}; } if (!workflow.connections[sourceNodeNameKey]) { workflow.connections[sourceNodeNameKey] = {}; } if (!workflow.connections[sourceNodeNameKey][source_node_output_name]) { workflow.connections[sourceNodeNameKey][source_node_output_name] = []; } // n8n expects an array of connection arrays for each output handle. // Each inner array represents a set of connections originating from the same output point if it splits. // For a simple new connection, we add it as a new chain: [newConnectionObject] workflow.connections[sourceNodeNameKey][source_node_output_name].push([newConnectionObject]); await promises_1.default.writeFile(filePath, JSON.stringify(workflow, null, 2)); console.error(`[DEBUG] Added connection from ${sourceNodeNameKey}:${source_node_output_name} to ${targetNodeNameValue}:${target_node_input_name} in workflow ${workflow_name}`); // Add a special note for AI connections let message = "Connection added successfully"; if ((isLangChainSource || isLangChainTarget) && isAIConnection) { message += ". Note: For LangChain nodes, connections might need specific output/input names and connection direction. If connections don't appear in n8n UI, check that:"; message += "\n- Models connect TO the agent using 'ai_languageModel' ports"; message += "\n- Tools connect TO the agent using 'ai_tool' ports"; message += "\n- Memory nodes connect TO the agent using 'ai_memory' ports"; } // Validate after modification try { const nodeTypes = await (0, nodeTypesLoader_1.loadNodeTypesForCurrentVersion)(path_1.default.resolve(__dirname, '../workflow_nodes'), (0, versioning_1.getCurrentN8nVersion)()); const report = (0, workflowValidator_1.validateAndNormalizeWorkflow)(workflow, nodeTypes); if (!report.ok || (report.warnings && report.warnings.length > 0)) { if (!report.ok) console.warn('[WARN] Workflow validation failed after add_connection', report.errors); return { content: [{ type: "text", text: JSON.stringify({ success: true, message, connection: { from: `${sourceNode.name} (${source_node_id})`, fromOutput: source_node_output_name, to: `${targetNode.name} (${target_node_id})`, toInput: target_node_input_name, index: target_node_input_index }, validation: { ok: report.ok, errors: report.errors, warnings: report.warnings, nodeIssues: report.nodeIssues } }) }] }; } } catch (e) { console.warn('[WARN] Validation step errored after add_connection:', e?.message || e); } return { content: [{ type: "text", text: JSON.stringify({ success: true, message, connection: { from: `${sourceNode.name} (${source_node_id})`, fromOutput: source_node_output_name, to: `${targetNode.name} (${target_node_id})`, toInput: target_node_input_name, index: target_node_input_index } }) }] }; } catch (error) { console.error("[ERROR] Failed to add connection:", error); return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "Failed to add connection: " + error.message }) }] }; } }); // Add AI Connections (special case for LangChain nodes) const addAIConnectionsParamsSchema = zod_1.z.object({ workflow_name: zod_1.z.string().describe("The Name of the workflow to add the AI connections to"), agent_node_id: zod_1.z.string().describe("The ID of the agent node that will use the model and tools"), model_node_id: zod_1.z.string().optional().describe("The ID of the language model node (optional)"), tool_node_ids: zod_1.z.array(zod_1.z.string()).optional().describe("Array of tool node IDs to connect to the agent (optional)"), memory_node_id: zod_1.z.string().optional().describe("The ID of the memory node (optional)"), // New optional nodes for extended AI wiring embeddings_node_id: zod_1.z.string().optional().describe("The ID of the embeddings node (optional)"), vector_store_node_id: zod_1.z.string().optional().describe("The ID of the vector store node (optional)"), vector_insert_node_id: zod_1.z.string().optional().describe("The ID of the vector store insert node (optional)"), vector_tool_node_id: zod_1.z.string().optional().describe("The ID of the Vector Store Question Answer Tool node (optional)"), workflow_path: zod_1.z.string().optional().describe("Optional direct path to the workflow file (absolute or relative to current working directory). If not provided, uses standard workflow_data directory approach.") }); server.tool("add_ai_connections", "Wire AI model, tools, and memory to an agent", addAIConnectionsParamsSchema.shape, async (params, _extra) => { console.error("[DEBUG] add_ai_connections called with:", params); const { workflow_name, agent_node_id, model_node_id, tool_node_ids, memory_node_id, embeddings_node_id, vector_store_node_id, vector_insert_node_id, vector_tool_node_id, workflow_path } = params; if (!model_node_id && (!tool_node_ids || tool_node_ids.length === 0) && !memory_node_id) { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "At least one of model_node_id, memory_node_id, or tool_node_ids must be provided" }) }] }; } try { let filePath = (0, workspace_1.resolveWorkflowPath)(workflow_name, workflow_path); // Auto-detect workspace when path not provided and default path doesn't exist try { if (!workflow_path) { await promises_1.default.access(filePath).catch(async () => { const detected = await (0, workspace_1.tryDetectWorkspaceForName)(workflow_name); if (detected) filePath = detected; }); } } catch { } let workflow; try { const data = await promises_1.default.readFile(filePath, 'utf8'); workflow = JSON.parse(data); } catch (readError) { if (readError.code === 'ENOENT') { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Workflow with name ${workflow_name} not found at ${filePath}` }) }] }; } throw readError; } // First verify all nodes exist const agentNode = workflow.nodes.find(node => node.id === agent_node_id); if (!agentNode) { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Agent node with ID ${agent_node_id} not found in workflow ${workflow_name}` }) }] }; } // Enforce that the target node is actually an Agent (per node definition wiring.role) try { const workflowNodesRootDir = path_1.default.resolve(__dirname, '../workflow_nodes'); const nodeTypes = await (0, nodeTypesLoader_1.loadNodeTypesForCurrentVersion)(workflowNodesRootDir, (0, versioning_1.getCurrentN8nVersion)() || undefined); const agentType = nodeTypes.getByNameAndVersion(agentNode.type, agentNode.typeVersion); const role = agentType?.description?.wiring?.role; const looksLikeAgent = String(agentNode.type || '').toLowerCase().includes('agent'); if (role !== 'agent' && !looksLikeAgent) { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Node ${agentNode.name} (${agent_node_id}) is not an Agent node (type=${agentNode.type}).` }) }] }; } } catch (e) { console.warn('[WARN] Could not verify agent node type against node definitions:', e?.message || e); } let modelNode = null; if (model_node_id) { modelNode = workflow.nodes.find(node => node.id === model_node_id); if (!modelNode) { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Model node with ID ${model_node_id} not found in workflow ${workflow_name}` }) }] }; } } let memoryNode = null; if (memory_node_id) { memoryNode = workflow.nodes.find(node => node.id === memory_node_id); if (!memoryNode) { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Memory node with ID ${memory_node_id} not found in workflow ${workflow_name}` }) }] }; } } let embeddingsNode = null; if (embeddings_node_id) { embeddingsNode = workflow.nodes.find(node => node.id === embeddings_node_id) || null; if (!embeddingsNode) { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Embeddings node with ID ${embeddings_node_id} not found in workflow ${workflow_name}` }) }] }; } } let vectorStoreNode = null; if (vector_store_node_id) { vectorStoreNode = workflow.nodes.find(node => node.id === vector_store_node_id) || null; if (!vectorStoreNode) { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Vector store node with ID ${vector_store_node_id} not found in workflow ${workflow_name}` }) }] }; } } let vectorInsertNode = null; if (vector_insert_node_id) { vectorInsertNode = workflow.nodes.find(node => node.id === vector_insert_node_id) || null; if (!vectorInsertNode) { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Vector insert node with ID ${vector_insert_node_id} not found in workflow ${workflow_name}` }) }] }; } } let vectorToolNode = null; if (vector_tool_node_id) { vectorToolNode = workflow.nodes.find(node => node.id === vector_tool_node_id) || null; if (!vectorToolNode) { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Vector tool node with ID ${vector_tool_node_id} not found in workflow ${workflow_name}` }) }] }; } } const toolNodes = []; if (tool_node_ids && tool_node_ids.length > 0) { for (const toolId of tool_node_ids) { const toolNode = workflow.nodes.find(node => node.id === toolId); if (!toolNode) { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: `Tool node with ID ${toolId} not found in workflow ${workflow_name}` }) }] }; } toolNodes.push(toolNode); } } if (!workflow.connections) { workflow.connections = {}; } // For AI nodes in n8n, we need to: // 1. Language model connects TO the agent using ai_languageModel ports // 2. Tools connect TO the agent using ai_tool ports // 3. Memory nodes connect TO the agent using ai_memory ports // Extended wiring: // 4. Embeddings connect TO Vector Store using ai_embeddings // 5. Vector Store connects TO Vector Insert using ai_document // 6. Vector Store connects TO Vector QA Tool using ai_vectorStore // 7. Language model connects TO Vector QA Tool using ai_languageModel // Create the language model connection if a model node was provided if (modelNode) { const modelNodeName = modelNode.name; // Initialize model node's connections if needed if (!workflow.connections[modelNodeName]) { workflow.connections[modelNodeName] = {}; } // Add the AI language model output if (!workflow.connections[modelNodeName]["ai_languageModel"]) { workflow.connections[modelNodeName]["ai_languageModel"] = []; } // Add connection from model to agent const modelConnection = { node: agentNode.name, type: "ai_languageModel", index: 0 }; // Check if this connection already exists const existingModelConnection = workflow.connections[modelNodeName]["ai_languageModel"].some(conn => conn.some(detail => detail.node === agentNode.name && detail.type === "ai_languageModel")); if (!existingModelConnection) { workflow.connections[modelNodeName]["ai_languageModel"].push([modelConnection]); console.error(`[DEBUG] Added AI language model connection from ${modelNodeName} to ${agentNode.name}`); } else { console.error(`[DEBUG] AI language model connection from ${modelNodeName} to ${agentNode.name} already exists`); } } // Create memory connection if a memory node was provided if (memoryNode) { const memoryNodeName = memoryNode.name; // Initialize memory node's connections if needed if (!workflow.connections[memoryNodeName]) { workflow.connections[memoryNodeName] = {}; } // Add the AI memory output if (!workflow.connections[memoryNodeName]["ai_memory"]) { workflow.connections[memoryNodeName]["ai_memory"] = []; } // Add connection from memory to agent const memoryConnection = { node: agentNode.name, type: "ai_memory", index: 0 }; // Check if this connection already exists const existingMemoryConnection = workflow.connections[memoryNodeName]["ai_memory"].some(conn => conn.some(detail => detail.node === agentNode.name && detail.type === "ai_memory")); if (!existingMemoryConnection) { workflow.connections[memoryNodeName]["ai_memory"].push([memoryConnection]); console.error(`[DEBUG] Added AI memory connection from ${memoryNodeName} to ${agentNode.name}`); } else { console.error(`[DEBUG] AI memory connection from ${memoryNodeName} to ${agentNode.name} already exists`); } } // Create tool connections if tool nodes were provided if (toolNodes.length > 0) { for (const toolNode of toolNodes) { const toolNodeName = toolNode.name; // Initialize tool node's connections if needed if (!workflow.connections[toolNodeName]) { workflow.connections[toolNodeName] = {}; } // Add the AI tool output if (!workflow.connections[toolNodeName]["ai_tool"]) { workflow.connections[toolNodeName]["ai_tool"] = []; } // Add connection from tool to agent const toolConnection = { node: agentNode.name, type: "ai_tool", index: 0 }; // Check if this connection already exists (dedupe) const exists = workflow.connections[toolNodeName]["ai_tool"].some((group) => Array.isArray(group) && group.some((d) => d && d.node === agentNode.name && d.type === "ai_tool")); if (!exists) { workflow.connections[toolNodeName]["ai_tool"].push([toolConnection]); console.error(`[DEBUG] Added AI tool connection from ${toolNodeName} to ${agentNode.name}`); } else { console.error(`[DEBUG] AI tool connection from ${toolNodeName} to ${agentNode.name} already exists`); } } } // Embeddings → Vector Store (ai_embeddings) if (embeddingsNode && vectorStoreNode) { const fromName = embeddingsNode.name; const toName = vectorStoreNode.name; if (!workflow.connections[fromName]) workflow.connections[fromName] = {}; if (!workflow.connections[fromName]["ai_embeddings"]) workflow.connections[fromName]["ai_embeddings"] = []; const exists = workflow.connections[fromName]["ai_embeddings"].some((group) => Array.isArray(group) && group.some((d) => d && d.node === toName && d.type === "ai_embeddings")); if (!exists) { workflow.connections[fromName]["ai_embeddings"].push([{ node: toName, type: "ai_embeddings", index: 0 }]); console.error(`[DEBUG] Added embeddings connection from ${fromName} to ${toName} (ai_embeddings)`); } } // Vector Store → Vector Insert (ai_document) if (vectorStoreNode && vectorInsertNode) { const fromName = vectorStoreNode.name; const toName = vectorInsertNode.name; if (!workflow.connections[fromName]) workflow.connections[fromName] = {}; if (!workflow.connections[fromName]["ai_document"]) workflow.connections[fromName]["ai_document"] = []; const exists = workflow.connections[fromName]["ai_document"].some((group) => Array.isArray(group) && group.some((d) => d && d.node === toName && d.type === "ai_document")); if (!exists) { workflow.connections[fromName]["ai_document"].push([{ node: toName, type: "ai_document", index: 0 }]); console.error(`[DEBUG] Added vector document connection from ${fromName} to ${toName} (ai_document)`); } } // Vector Store → Vector QA Tool (ai_vectorStore) if (vectorStoreNode) { const toNode = vectorToolNode || (toolNodes || []).find(n => String(n.type).includes('toolVectorStore')) || null; if (toNode) { const fromName = vectorStoreNode.name; const toName = toNode.name; if (!workflow.connections[fromName]) workflow.connections[fromName] = {}; if (!workflow.connections[fromName]["ai_vectorStore"]) workflow.connections[fromName]["ai_vectorStore"] = []; const exists = workflow.connections[fromName]["ai_vectorStore"].some((group) => Array.isArray(group) && group.some((d) => d && d.node === toName && d.type === "ai_vectorStore")); if (!exists) { workflow.connections[fromName]["ai_vectorStore"].push([{ node: toName, type: "ai_vectorStore", index: 0 }]); console.error(`[DEBUG] Added vector store connection from ${fromName} to ${toName} (ai_vectorStore)`); } } } // Language Model → Vector QA Tool (ai_languageModel) if (modelNode) { const toNode = vectorToolNode || (toolNodes || []).find(n => String(n.type).includes('toolVectorStore')) || null; if (toNode) { const fromName = modelNode.name; const toName = toNode.name; if (!workflow.connections[fromName]) workflow.connections[fromName] = {}; if (!workflow.connections[fromName]["ai_languageModel"]) workflow.connections[fromName]["ai_languageModel"] = []; const exists = workflow.connections[fromName]["ai_languageModel"].some((group) => Array.isArray(group) && group.some((d) => d && d.node === toName && d.type === "ai_languageModel")); if (!exists) { workflow.connections[fromName]["ai_languageModel"].push([{ node: toName, type: "ai_languageModel", index: 0 }]); console.error(`[DEBUG] Added model connection from ${fromName} to ${toName} (ai_languageModel)`); } } } // Save the updated workflow await promises_1.default.writeFile(filePath, JSON.stringify(workflow, null, 2)); // Build summary of connections created const connectionsSummary = []; if (modelNode) { connectionsSummary.push({ from: `${modelNode.name} (${model_node_id})`, fromOutput: "ai_languageModel", to: `${agentNode.name} (${agent_node_id})`, toInput: "ai_languageModel" }); } if (memoryNode) { connectionsSummary.push({ from: `${memoryNode.name} (${memory_node_id})`, fromOutput: "ai_memory", to: `${agentNode.name} (${agent_node_id})`, toInput: "ai_memory" }); } toolNodes.forEach(toolNode => { connectionsSummary.push({ from: `${toolNode.name} (${toolNode.id})`, fromOutput: "ai_tool", to: `${agentNode.name} (${agent_node_id})`, toInput: "ai_tool" }); }); if (embeddingsNode && vectorStoreNode) { connectionsSummary.push({ from: `${embeddingsNode.name} (${embeddings_node_id})`, fromOutput: "ai_embeddings", to: `${vectorStoreNode.name} (${vector_store_node_id})`, toInput: "ai_embeddings" }); } if (vectorStoreNode && vectorInsertNode) { connectionsSummary.push({ from: `${vectorStoreNode.name} (${vector_store_node_id})`, fromOutput: "ai_document", to: `${vectorInsertNode.name} (${vector_insert_node_id})`, toInput: "ai_document" }); } if (vectorStoreNode) { const toNode = vectorToolNode || (toolNodes || []).find(n => String(n.type).includes('toolVectorStore')) || null; if (toNode) { connectionsSummary.push({ from: `${vectorStoreNode.name} (${vector_store_node_id})`, fromOutput: "ai_vectorStore", to: `${toNode.name} (${toNode.id})`, toInput: "ai_vectorStore" }); } } if (modelNode) { const toNode = vectorToolNode || (toolNodes || []).find(n => String(n.type).includes('toolVectorStore')) || null; if (toNode) { connectionsSummary.push({ from: `${modelNode.name} (${model_node_id})`, fromOutput: "ai_languageModel", to: `${toNode.name} (${toNode.id})`, toInput: "ai_languageModel" }); } } return { content: [{ type: "text", text: JSON.stringify({ success: true, message: "AI connections added successfully", connectionsCreated: connectionsSummary.length, connections: connectionsSummary }) }] }; } catch (error) { console.error("[ERROR] Failed to add AI connections:", error); return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "Failed to add AI connections: " + error.message }) }] }; } }); // Compose AI Workflow (high-level bulk operation) const composeAIWorkflowParamsSchema = zod_1.z.object({ workflow_name: zod_1.z.string().describe("Name of the workflow to compose/update"), n8n_version: zod_1.z.string().optional().describe("Target n8n version to use for node catalogs and compatibility"), plan: zod_1.z.object({ agent: zod_1.z.object({ node_type: zod_1.z.string().default("@n8n/n8n-nodes-langchain.agent"), node_name: zod_1.z.string().default("AI Agent") }).optional(), model: zod_1.z.object({ node_type: zod_1.z.string().default("@n8n/n8n-nodes-langchain.lmChatOpenAi"), node_name: zod_1.z.string().default("OpenAI Chat Model"), parameters: zod_1.z.record(zod_1.z.string(), zod_1.z.any()).optional() }).optional(), memory: zod_1.z.object({ node_type: zod_1.z.string().default("@n8n/n8n-nodes-langchain.memoryBufferWindow"), node_name: zod_1.z.string().default("Conversation Memory"), parameters: zod_1.z.record(zod_1.z.string(), zod_1.z.any()).optional() }).optional(), embeddings: zod_1.z.object({ node_type: zod_1.z.string().default("@n8n/n8n-nodes-langchain.embeddingsOpenAi"), node_name: zod_1.z.string().default("OpenAI Embeddings"), parameters: zod_1.z.record(zod_1.z.string(), zod_1.z.any()).optional() }).optional(), vector_store: zod_1.z.object({ node_type: zod_1.z.string().default("@n8n/n8n-nodes-langchain.vectorStoreInMemory"), node_name: zod_1.z.string().default("In-Memory Vector Store"), parameters: zod_1.z.record(zod_1.z.string(), zod_1.z.any()).optional() }).optional(), vector_insert: zod_1.z.object({ node_type: zod_1.z.string().default("@n8n/n8n-nodes-langchain.vectorStoreInMemoryInsert"), node_name: zod_1.z.string().default("In-Memory Vector Insert"), parameters: zod_1.z.record(zod_1.z.string(), zod_1.z.any()).optional() }).optional(), vector_tool: zod_1.z.object({ node_type: zod_1.z.string().default("@n8n/n8n-nodes-langchain.toolVectorStore"), node_name: zod_1.z.string().default("Vector QA Tool"), parameters: zod_1.z.record(zod_1.z.string(), zod_1.z.any()).optional() }).optional(), tools: zod_1.z.array(zod_1.z.object({ node_type: zod_1.z.string(), node_name: zod_1.z.string().optional(), parameters: zod_1.z.record(zod_1.z.string(), zod_1.z.any()).optional() })).optional(), trigger: zod_1.z.object({ node_type: zod_1.z.string().default("@n8n/n8n-nodes-langchain.chatTrigger"), node_name: zod_1.z.string().default("Start Chat Trigger"), parameters: zod_1.z.record(zod_1.z.string(), zod_1.z.any()).optional() }).optional() }).describe("High level plan of nodes to add and wire") }); server.tool("compose_ai_workflow", "Compose a complex AI workflow (agent + model + memory + embeddings + vector + tools + trigger) in one call, including wiring and basic validation.", composeAIWorkflowParamsSchema.shape, async (params, _extra) => { console.error("[DEBUG] compose_ai_workflow called with:", params?.plan ? Object.keys(params.plan) : []); const { workflow_name, plan } = params; try { // Step 1: ensure workflow exists (create if missing) await (0, workspace_1.ensureWorkflowDir)(); const filePath = (0, workspace_1.resolvePath)(path_1.default.join(workspace_1.WORKFLOW_DATA_DIR_NAME, `${workflow_name.replace(/[^a-z0-9_.-]/gi, '_')}.json`)); let workflow; try { const raw = await promises_1.default.readFile(filePath, 'utf8'); workflow = JSON.parse(raw); } catch (e) { // Create minimal workflow if missing workflow = { name: workflow_name, id: (0, id_1.generateUUID)(), nodes: [], connections: {}, active: false, pinData: {}, settings: { executionOrder: 'v1' }, versionId: (0, id_1.generateUUID)(), meta: { instanceId: (0, id_1.generateUUID)() }, tags: [] }; } // Helper to add a node with normalization const addNode = async (nodeType, nodeName, parameters, position) => { const { finalNodeType, finalTypeVersion } = (0, cache_1.normalizeNodeTypeAndVersion)(nodeType); const node = { id: (0, id_1.generateN8nId)(), type: finalNodeType, typeVersion: finalTypeVersion, position: [position?.x ?? 100, position?.y ?? 100], parameters: parameters || {}, name: nodeName }; workflow.nodes.push(node); return node; }; // Rough positions for readability const positions = { trigger: { x: 80, y: 80 }, agent: { x: 380, y: 80 }, model: { x: 380, y: -120 }, memory: { x: 380, y: 240 }, embeddings: { x: 720, y: -280 }, vstore: { x: 720, y: -120 }, vinsert: { x: 960, y: -200 }, vtool: { x: 640, y: 80 } }; // Step 2: add nodes from the plan const trigger = plan.trigger ? await addNode(plan.trigger.node_type, plan.trigger.node_name, plan.trigger.parameters, positions.trigger) : null; const agent = plan.agent ? await addNode(plan.agent.node_type, plan.agent.node_name, undefined, positions.agent) : null; const model = plan.model ? await addNode(plan.model.node_type, plan.model.node_name, plan.model.parameters, positions.model) : null; const memory = plan.memory ? await addNode(plan.memory.node_type, plan.memory.node_name, plan.memory.parameters, positions.memory) : null; const embeddings = plan.embeddings ? await addNode(plan.embeddings.node_type, plan.embeddings.node_name, plan.embeddings.parameters, positions.embeddings) : null; const vstore = plan.vector_store ? await addNode(plan.vector_store.node_type, plan.vector_store.node_name, plan.vector_store.parameters, positions.vstore) : null; const vinsert = plan.vector_insert ? await addNode(plan.vector_insert.node_type, plan.vector_insert.node_name, plan.vector_insert.parameters, positions.vinsert) : null; const vtool = plan.vector_tool ? await addNode(plan.vector_tool.node_type, plan.vector_tool.node_name, plan.vector_tool.parameters, positions.vtool) : null; const extraTools = []; if (Array.isArray(plan.tools)) { for (const t of plan.tools) extraTools.push(await addNode(t.node_type, t.node_name || t.node_type.split('.').pop() || 'Tool', t.parameters)); } // Step 3: wire standard connections const toolIds = [...(vtool ? [vtool.id] : []), ...extraTools.map(t => t.id)]; await promises_1.default.writeFile(filePath, JSON.stringify(workflow, null, 2)); // Re-load to use shared connection routine const res = await (async () => { const p = { workflow_name, agent_node_id: agent?.id, model_node_id: model?.id, memory_node_id: memory?.id, tool_node_ids: toolIds.length ? toolIds : undefined, embeddings_node_id: embeddings?.id, vector_store_node_id: vstore?.id, vector_insert_node_id: vinsert?.id, vector_tool_node_id: vtool?.id }; // Reuse internal implementation by calling same function body pattern // Simulate by directly updating file via add_ai_connections logic above: we'll call validate afterwards return p; })(); // Call the same underlying wiring by invoking add_ai_connections handler logic const wiringResult = await (async () => { const toolParams = { workflow_name, agent_node_id: agent?.id, model_node_id: model?.id, memory_node_id: memory?.id, tool_node_ids: toolIds.length ? toolIds : undefined, embeddings_node_id: embeddings?.id, vector_store_node_id: vstore?.id, vector_insert_node_id: vinsert?.id, vector_tool_node_id: vtool?.id }; // Inline invoke: replicate parameter pass through server since we are inside same process const resp = await (async () => { const parsed = await addAIConnectionsParamsSchema.parseAsync(toolParams); // Reuse the code path by calling the handler body: simplest is to write filePath and then re-run handler code // To avoid duplicate logic, we directly call the same block by re-reading and reusing the implemented routine above is non-trivial here. // As a compromise, we programmatically call the public add_connection tool multiple times for missing edges. return parsed; })(); // Instead of duplicating handler, connect minimal necessary edges using low-level add_connection helper implemented above const connect = async (from, fromOutput, to, toInput) => { if (!from || !to) return; let wfRaw = JSON.parse(await promises_1.default.readFile(filePath, 'utf8')); if (!wfRaw.connections) wfRaw.connections = {}; const fromName = from.name; if (!wfRaw.connections[fromName]) wfRaw.connections[fromName] = {}; if (!wfRaw.connections[fromName][fromOutput]) wfRaw.connections[fromName][fromOutput] = []; const exists = wfRaw.connections[fromName][fromOutput].some((group) => Array.isArray(group) && group.some((d) => d && d.node === to.name && d.type === toInput)); if (!exists) wfRaw.connections[fromName][fromOutput].push([{ node: to.name, type: toInput, index: 0 }]); await promises_1.default.writeFile(filePath, JSON.stringify(wfRaw, null, 2)); }; await connect(model, 'ai_languageModel', agent, 'ai_languageModel'); await connect(memory, 'ai_memory', agent, 'ai_memory'); await connect(embeddings, 'ai_embeddings', vstore, 'ai_embeddings'); await connect(vstore, 'ai_document', vinsert, 'ai_document'); await connect(vstore, 'ai_vectorStore', vtool, 'ai_vectorStore'); await connect(model, 'ai_languageModel', vtool, 'ai_languageModel'); await connect(vtool, 'ai_tool', agent, 'ai_tool'); await connect(trigger, 'main', agent, 'main'); return { success: true }; })(); // Validate const nodeTypes = await (0, nodeTypesLoader_1.loadNodeTypesForCurrentVersion)(path_1.default.resolve(__dirname, '../workflow_nodes'), (0, versioning_1.getCurrentN8nVersion)()); const finalRaw = await promises_1.default.readFile(filePath, 'utf8'); const finalWf = JSON.parse(finalRaw); const report = (0, workflowValidator_1.validateAndNormalizeWorkflow)(finalWf, nodeTypes); return { content: [{ type: 'text', text: JSON.stringify({ success: true, workflowId: finalWf.id, wiring: wiringResult, validation: { ok: report.ok, errors: report.errors, warnings: report.warnings, nodeIssues: report.nodeIssues } }) }] }; } catch (error) { console.error('[ERROR] compose_ai_workflow failed:', error); return { content: [{ type: 'text', text: JSON.stringify({ success: false, error: error.message }) }] }; } }); // List Available Nodes const listAvailableNodesParamsSchema = zod_1.z.object({ search_term: zod_1.z.string().optional().describe("Filter by name, type, or description. For LangChain nodes, try 'langchain', roles like 'agent', 'lmChat'/'llm', 'tool', 'memory', or provider names such as 'qdrant', 'weaviate', 'milvus', 'openai', 'anthropic'."), n8n_version: zod_1.z.string().optional().describe("Filter nodes by N8N version compatibility. If not provided, uses current configured N8N version."), limit: zod_1.z.number().int().positive().max(1000).optional().describe("Maximum number of nodes to return"), cursor: zod_1.z.string().optional().describe("Opaque cursor for pagination; pass back to get the next page"), // Enable smart tag-style matching (e.g., 'llm' → 'lmChat', provider names) tags: zod_1.z.boolean().optional().describe("Enable tag-style synonym search (e.g., 'llm' → 'lmChat', providers). Defaults to true."), // How to combine multiple search terms: 'and' requires all tokens, 'or' matches any token_logic: zod_1.z.enum(['and', 'or']).optional().describe("When multiple terms are provided, require all terms ('and') or any ('or'). Defaults to 'or'.") }); server.tool("list_available_nodes", "List available node types. Supports tag-style synonym search and multi-token queries. By default, multiple terms use OR logic (e.g., 'webhook trigger' matches either). Set token_logic='and' to require all tokens. Disable synonyms with tags=false. Tips: search 'langchain', roles like 'agent', 'lmChat/llm', 'tool', 'memory', or provider names like 'qdrant', 'weaviate', 'milvus', 'openai', 'anthropic'.", listAvailableNodesParamsSchema.shape, async (params, _extra) => { console.error("[DEBUG] list_available_nodes called with params:", params); let availableNodes = []; // Root directory that contains either JSON files or versioned subdirectories // When compiled, this file lives in dist, so workflow_nodes is one level up const workflowNodesRootDir = path_1.default.resolve(__dirname, '../workflow_nodes'); // We'll compute an "effective" version to use both for reading files and filtering let effectiveVersion = params.n8n_version || (0, versioning_1.getCurrentN8nVersion)() || undefined; const hasExplicitVersion = !!params.n8n_version && params.n8n_version.trim() !== ''; try { // knownNodeBaseCasings should ideally be populated at startup by loadKnownNodeBaseTypes. // If it's empty here, it means initial load failed or directory wasn't found then. // We might not need to reload it here if startup handles it, but a check doesn't hurt. if ((0, cache_1.getNodeInfoCache)().size === 0 && (0, workspace_1.getWorkspaceDir)() !== process.cwd()) { console.warn("[WARN] nodeInfoCache is empty in list_available_nodes. Attempting to reload node type information."); // For now, if cache is empty, it means startup failed to load them. // The function will proceed and likely return an empty list or whatever it finds if workflowNodesDir is accessible now. } // Determine if we have versioned subdirectories and pick the exact version directory when available let workflowNodesDir = workflowNodesRootDir; try { const entries = await promises_1.default.readdir(workflowNodesRootDir, { withFileTypes: true }); const versionDirs = entries.filter(e => e.isDirectory?.() === true).map(e => e.name); if (versionDirs.length > 0) { const targetVersion = params.n8n_version || (0, versioning_1.getCurrentN8nVersion)(); if (targetVersion && versionDirs.includes(targetVersion)) { workflowNodesDir = path_1.default.join(workflowNodesRootDir, targetVersion); effectiveVersion = targetVersion; } else if (!targetVersion) { // No target specified: choose highest semver directory const parse = (v) => v.split('.').map(n => parseInt(n, 10) || 0); versionDirs.sort((a, b) => { const [a0, a1, a2] = parse(a); const [b0, b1, b2] = parse(b); if (a0 !== b0) return b0 - a0; if (a1 !== b1) return b1 - a1; return b2 - a2; }); workflowNodesDir = path_1.default.join(workflowNodesRootDir, versionDirs[0]); effectiveVersion = versionDirs[0]; } else { // Exact version requested but not found; keep root to avoid false empty, but log a clear warning console.warn(`[WARN] Requested N8N version directory '${targetVersion}' not found under workflow_nodes. Available: ${versionDirs.join(', ')}`); // Fallback: if there is a latest dir, use it so we still return something const parse = (v) => v.split('.').map(n => parseInt(n, 10) || 0); versionDirs.sort((a, b) => { const [a0, a1, a2] = parse(a); const [b0, b1, b2] = parse(b); if (a0 !== b0) return b0 - a0; if (a1 !== b1) return b1 - a1; return b2 - a2; }); workflowNodesDir = path_1.default.join(workflowNodesRootDir, versionDirs[0]); effectiveVersion = versionDirs[0]; } } } catch { // If reading entries fails, fall back to root and let the next readdir handle errors } console.error(`[DEBUG] Reading node definitions from: ${workflowNodesDir}`); const files = await promises_1.default.readdir(workflowNodesDir); const suffix = ".json"; const allParsedNodes = []; // Temporary array to hold all nodes before filtering for (const file of files) { if (file.endsWith(suffix) && file !== workspace_1.WORKFLOWS_FILE_NAME /* ignore old combined file */) { const filePath = path_1.default.join(workflowNodesDir, file); try { const fileContent = await promises_1.default.readFile(filePath, 'utf8'); const nodeDefinition = JSON.parse(fileContent); if (nodeDefinition.nodeType && nodeDefinition.displayName && nodeDefinition.properties) { // Normalize version(s) to numbers to avoid type mismatches during compatibility checks const rawVersion = nodeDefinition.version ?? 1; const normalizedVersion = Array.isArray(rawVersion) ? rawVersion .map((v) => typeof v === 'number' ? v : parseFloat(String(v))) .filter((v) => !Number.isNaN(v)) : (typeof rawVersion === 'number' ? rawVersion : parseFloat(String(rawVersion))); allParsedNodes.push({ nodeType: nodeDefinition.nodeType, displayName: nodeDefinition.displayName, description: nodeDefinition.description || "", version: normalizedVersion, properties: nodeDefinition.properties, credentialsConfig: nodeDefinition.credentialsConfig || [], categories: nodeDefinition.categories || [], // Also add simplified versions of the node type for reference simpleName: nodeDefinition.nodeType.includes('n8n-nodes-base.') ? nodeDefinition.nodeType.split('n8n-nodes-base.')[1] : nodeDefinition.nodeType }); } else { console.warn(`[WARN] File ${file} does not seem to be a valid node definition. Skipping.`); } } catch (parseError) { console.warn(`[WARN] Failed to parse ${file}: ${parseError.message}. Skipping.`); } } } if (params.search_term && params.search_term.trim() !== "") { // Tokenized and tag-aware search (supports multi-word like "webhook trigger") const raw = params.search_term.trim().toLowerCase(); const baseTokens = raw.split(/[\s,]+/).filter(Boolean); const useTags = params.tags !== false; // default true const tokenLogic = params.token_logic === 'and' ? 'and' : 'or'; // Known synonym tags to expand common queries const synonymMap = { llm: ['lmchat', 'language', 'model', 'chat', 'openai', 'anthropic', 'mistral', 'groq', 'xai', 'vertex', 'gpt'], agent: ['tools', 'tool', 'actions'], tool: ['tools', 'agent'], memory: ['buffer', 'vector', 'memory'], vector: ['qdrant', 'weaviate', 'milvus', 'pinecone', 'pgvector', 'chromadb', 'faiss'], embedding: ['embed', 'embeddings'], webhook: ['trigger', 'http'], trigger: ['start', 'webhook'] }; const expandedTokensSet = new Set(baseTokens); if (useTags) { for (const t of baseTokens) { if (synonymMap[t]) { for (const syn of synonymMap[t]) expandedTokensSet.add(syn); } } } const expandedTokens = Array.from(expandedTokensSet); availableNodes = allParsedNodes.filter(node => { const parts = []; if (node.displayName) parts.push(String(node.displayName)); if (node.nodeType) parts.push(String(node.nodeType)); if (node.description) parts.push(String(node.description)); if (node.simpleName) parts.push(String(node.simpleName)); if (node.categories && Array.isArray(node.categories)) parts.push(...node.categories.map((c) => String(c))); if (node.properties && Array.isArray(node.properties)) { for (const prop of node.properties) { if (prop?.name) parts.push(String(prop.name)); if (prop?.displayName) parts.push(String(prop.displayName)); if (prop?.options && Array.isArray(prop.options)) { for (const opt of prop.options) { if (opt?.name) parts.push(String(opt.name)); if (opt?.value) parts.push(String(opt.value)); } } } } const searchableText = parts.join(' ').toLowerCase(); if (expandedTokens.length === 0) return true; if (tokenLogic === 'or') { return expandedTokens.some(t => searchableText.includes(t)); } // Default AND logic return expandedTokens.every(t => searchableText.includes(t)); }); console.log(`[DEBUG] Filtered nodes by '${params.search_term}' (tags=${useTags}, logic=${tokenLogic}). Found ${availableNodes.length} of ${allParsedNodes.length}.`); } else { availableNodes = allParsedNodes; // No search term, return all nodes } // Additional sensitive diagnostic log for search inputs and derived counts try { const { sensitiveLogger } = require('./utils/logger'); sensitiveLogger.debug(`list_available_nodes diagnostics: search_term='${params.search_term || ''}', totalParsed=${allParsedNodes.length}, matched=${availableNodes.length}`); } catch { } if (availableNodes.length === 0 && allParsedNodes.length > 0 && params.search_term) { console.warn(`[WARN] No nodes matched the search term: '${params.search_term}'.`); } else if (allParsedNodes.length === 0) { console.warn("[WARN] No node definitions found in workflow_nodes. Ensure the directory is populated with JSON files from the scraper."); } // Filter by N8N version compatibility if specified const targetVersion = effectiveVersion || undefined; let versionFilteredNodes = availableNodes; if (targetVersion && targetVersion !== 'latest') { versionFilteredNodes = availableNodes.filter(node => { // Check if node is supported in the target N8N version const targetVersionInfo = (0, versioning_1.getSupportedN8nVersions)().get(targetVersion); if (!targetVersionInfo) { // If target version info isn't loaded yet, don't over-filter return true; } const supportedVersions = targetVersionInfo.supportedNodes.get(node.nodeType); if (!supportedVersions) { // If specific node type not present, assume supported to avoid false negatives return true; } // Check if any of the node's versions are supported const nodeVersionsRaw = Array.isArray(node.version) ? node.version : [node.version]; const nodeVersions = nodeVersionsRaw .map((v) => typeof v === 'number' ? v : parseFloat(String(v))) .filter((v) => !Number.isNaN(v)); return nodeVersions.some((v) => supportedVersions.has(v)); }); console.error(`[DEBUG] Filtered ${availableNodes.length} nodes to ${versionFilteredNodes.length} compatible with N8N ${targetVersion}`); } // Format the results to be more user-friendly and informative const formattedNodes = versionFilteredNodes.map(node => { const targetVersionInfo = (0, versioning_1.getSupportedN8nVersions)().get(targetVersion || (0, versioning_1.getCurrentN8nVersion)() || "1.108.0"); const supportedVersions = targetVersionInfo?.supportedNodes.get(node.nodeType); const compatibleVersions = supportedVersions ? Array.from(supportedVersions) : []; return { // Keep only the most relevant information nodeType: node.nodeType, // Full node type with correct casing displayName: node.displayName, description: node.description, simpleName: node.simpleName, // The part after n8n-nodes-base categories: node.categories || [], version: node.version, compatibleVersions: compatibleVersions.length > 0 ? compatibleVersions : [node.version], // Count parameters but don't include details to keep response size manageable parameterCount: node.properties ? node.properties.length : 0, // Provide a small, safe preview of properties by default propertiesPreview: (() => { try { const props = Array.isArray(node.properties) ? node.properties : []; const MAX_PROPS = 5; return props.slice(0, MAX_PROPS).map((p) => { const optionValues = Array.isArray(p?.options) ? p.options .slice(0, 5) .map((o) => (o?.value ?? o?.name)) .filter((v) => v !== undefined) : undefined; const preview = { name: p?.name ?? p?.displayName, displayName: p?.displayName ?? p?.name, type: p?.type ?? (Array.isArray(p?.options) ? 'options' : undefined) }; if (typeof p?.default !== 'undefined') preview.default = p.default; if (p?.required === true) preview.required = true; if (optionValues && optionValues.length) preview.optionValues = optionValues; return preview; }); } catch { return []; } })() }; }); // Ranking boost: prioritize the core Webhook node at the top of results when present // This reflects common usage in n8n where the Webhook node is frequently the starting trigger const orderedNodes = (() => { // Work on a shallow copy to avoid mutating the base array const copy = formattedNodes.slice(); const isWebhookNode = (n) => { const dn = String(n?.displayName || '').toLowerCase(); const sn = String(n?.simpleName || '').toLowerCase(); const nt = String(n?.nodeType || '').toLowerCase(); return dn === 'webhook' || sn === 'webhook' || nt.endsWith('.webhook'); }; const webhookIndex = copy.findIndex(isWebhookNode); if (webhookIndex > 0) { const [webhookNode] = copy.splice(webhookIndex, 1); copy.unshift(webhookNode); } return copy; })(); // Include usage guidance in the response // usage guidance moved to rules; keep formattedNodes only // Apply pagination const startIndex = params?.cursor ? Number(params.cursor) || 0 : 0; const limit = params?.limit ?? orderedNodes.length; const page = orderedNodes.slice(startIndex, startIndex + limit); const nextIndex = startIndex + limit; const nextCursor = nextIndex < orderedNodes.length ? String(nextIndex) : null; // Return the formatted response return { content: [{ type: "text", text: JSON.stringify({ success: true, nodes: page, total: orderedNodes.length, nextCursor, filteredFor: targetVersion ? `N8N ${targetVersion}` : "All versions", currentN8nVersion: targetVersion || (0, versioning_1.getCurrentN8nVersion)(), usageGuidance: undefined }) }] }; } catch (error) { console.error("[ERROR] Failed to list available nodes:", error); if (error.code === 'ENOENT') { console.warn("[WARN] workflow_nodes directory not found. Cannot list available nodes."); return { content: [{ type: "text", text: JSON.stringify({ success: true, nodes: [], message: "workflow_nodes directory not found." }) }] }; } return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "Failed to list available nodes: " + error.message }) }] }; } }); // N8N Version Management Tools // Get N8N Version Info server.tool("get_n8n_version_info", "Get N8N version info and capabilities", {}, async (_params, _extra) => { console.error("[DEBUG] get_n8n_version_info called"); try { const supportedVersionsList = Array.from((0, versioning_1.getSupportedN8nVersions)().keys()).sort((a, b) => parseFloat(b) - parseFloat(a)); const currentInfo = (0, versioning_1.getN8nVersionInfo)() ? { version: (0, versioning_1.getCurrentN8nVersion)(), capabilities: (0, versioning_1.getN8nVersionInfo)().capabilities, supportedNodesCount: (0, versioning_1.getN8nVersionInfo)().supportedNodes.size } : null; return { content: [{ type: "text", text: JSON.stringify({ success: true, currentVersion: (0, versioning_1.getCurrentN8nVersion)(), currentVersionInfo: currentInfo, supportedVersions: supportedVersionsList, versionSource: process.env.N8N_VERSION ? "environment_override" : (process.env.N8N_API_URL ? "api_detection" : "default"), capabilities: (0, versioning_1.getN8nVersionInfo)()?.capabilities || [] }) }] }; } catch (error) { console.error("[ERROR] Failed to get N8N version info:", error); return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "Failed to get N8N version info: " + error.message }) }] }; } }); // Validate Workflow tool const validateWorkflowParamsSchema = zod_1.z.object({ workflow_name: zod_1.z.string().describe("The Name of the workflow to validate"), workflow_path: zod_1.z.string().optional().describe("Optional direct path to the workflow file") }); server.tool("validate_workflow", "Validate a workflow file against known node schemas", validateWorkflowParamsSchema.shape, async (params, _extra) => { console.error("[DEBUG] validate_workflow called with:", params); try { let filePath = (0, workspace_1.resolveWorkflowPath)(params.workflow_name, params.workflow_path); try { if (!params.workflow_path) { await promises_1.default.access(filePath).catch(async () => { const detected = await (0, workspace_1.tryDetectWorkspaceForName)(params.workflow_name); if (detected) filePath = detected; }); } } catch { } const raw = await promises_1.default.readFile(filePath, 'utf8'); const workflow = JSON.parse(raw); const nodeTypes = await (0, nodeTypesLoader_1.loadNodeTypesForCurrentVersion)(path_1.default.resolve(__dirname, '../workflow_nodes'), (0, versioning_1.getCurrentN8nVersion)()); const report = (0, workflowValidator_1.validateAndNormalizeWorkflow)(workflow, nodeTypes); // For validate_workflow tool, treat all warnings as errors to ensure AI agents don't ignore them const allErrors = [...report.errors]; // Convert warnings to errors for this tool, but skip benign trigger-specific AI port warnings if (report.warnings.length > 0) { for (const warning of report.warnings) { const warnType = String(warning?.details?.type || '').toLowerCase(); const isTriggerAiPortWarning = warning.code === 'ai_node_without_ai_ports' && warnType.includes('chattrigger'); if (isTriggerAiPortWarning) continue; allErrors.push({ code: warning.code, message: warning.message, nodeName: warning.nodeName, details: warning.details }); } } // Additional validation ONLY for validate_workflow tool: // Ensure every enabled node is connected to the main workflow chain try { const normalized = report.normalized || { nodes: {}, connectionsBySource: {}, connectionsByDestination: {} }; const nodesByName = normalized.nodes || {}; const connectionsBySource = normalized.connectionsBySource || {}; const startNode = report.startNode; // Detect legacy IF branching shape where connections use top-level "true"/"false" keys // instead of encoding both branches as outputs on the "main" connection array. // This commonly causes many downstream nodes to be flagged as not in the main chain. const legacyBranchNodes = []; try { for (const [src, byType] of Object.entries(connectionsBySource)) { const keys = Object.keys(byType || {}); const hasBoolean = keys.includes('true') || keys.includes('false'); const numericKeys = keys.filter((k) => /^\d+$/.test(k)); if (hasBoolean || numericKeys.length > 0) legacyBranchNodes.push({ name: src, hasBoolean, numericKeys }); } } catch { /* best-effort detection only */ } if (startNode) { // Build adjacency for 'main' edges (forward only) // Be lenient for legacy IF shapes: treat 'true'/'false' keys as main-like for reachability const mainNeighbors = {}; const getMainLikeGroups = (byType) => { const groupsMain = (byType || {}).main || []; if (Array.isArray(groupsMain) && groupsMain.length > 0) return groupsMain; const tfTrue = Array.isArray((byType || {}).true) ? (byType || {}).true : []; const tfFalse = Array.isArray((byType || {}).false) ? (byType || {}).false : []; if (tfTrue.length > 0 || tfFalse.length > 0) return [...tfTrue, ...tfFalse]; // Handle numeric switch outputs: '0','1','2',... const numericKeys = Object.keys(byType || {}).filter((k) => /^\d+$/.test(k)).sort((a, b) => parseInt(a) - parseInt(b)); if (numericKeys.length > 0) { const out = []; for (const k of numericKeys) { const arr = (byType || {})[k]; if (Array.isArray(arr)) out.push(...arr); } return out; } return []; }; for (const [src, byType] of Object.entries(connectionsBySource)) { const groups = getMainLikeGroups(byType); for (const group of groups || []) { for (const conn of group || []) { if (!conn) continue; if (!mainNeighbors[src]) mainNeighbors[src] = new Set(); mainNeighbors[src].add(conn.node); } } } // BFS from start via 'main' edges const reachableMain = new Set(); const queue = []; reachableMain.add(startNode); queue.push(startNode); while (queue.length > 0) { const cur = queue.shift(); const neigh = Array.from(mainNeighbors[cur] || []); for (const n of neigh) { if (!reachableMain.has(n)) { reachableMain.add(n); queue.push(n); } } } // Build undirected adjacency for all types to include attached AI/model/tool/memory nodes const allNeighbors = {}; const addUndirected = (a, b) => { if (!allNeighbors[a]) allNeighbors[a] = new Set(); if (!allNeighbors[b]) allNeighbors[b] = new Set(); allNeighbors[a].add(b); allNeighbors[b].add(a); }; for (const [src, byType] of Object.entries(connectionsBySource)) { for (const groups of Object.values(byType || {})) { for (const group of groups || []) { for (const conn of group || []) { if (!conn) continue; addUndirected(src, conn.node); } } } } // Expand from reachableMain using undirected edges to include attached non-main neighbors const reachableExtended = new Set(reachableMain); const q2 = Array.from(reachableMain); while (q2.length > 0) { const cur = q2.shift(); const neigh = Array.from(allNeighbors[cur] || []); for (const n of neigh) { if (!reachableExtended.has(n)) { reachableExtended.add(n); q2.push(n); } } } // Always strict now; multiple chains are not allowed const strictMainOnly = true; const targetSet = strictMainOnly ? reachableMain : reachableExtended; // Any enabled node not in targetSet is disconnected from main chain → error for (const [name, node] of Object.entries(nodesByName)) { if (node.disabled === true) continue; if (!targetSet.has(name)) { allErrors.push({ code: 'node_not_in_main_chain', message: `Node "${name}" (ID: ${node.id || 'unknown'}) is not connected to the main workflow chain starting at "${startNode}"`, nodeName: name, details: { nodeId: node.id, type: node.type } }); } } // Emit a targeted, actionable error for each IF node using legacy boolean keys // to guide users toward using main[0] (true) and main[1] (false) plus proper Merge inputs. if (legacyBranchNodes.length > 0) { for (const entry of legacyBranchNodes) { const node = nodesByName[entry.name] || {}; const shapeKeys = Object.keys(connectionsBySource[entry.name] || {}); if (entry.hasBoolean && entry.numericKeys.length > 0) { allErrors.push({ code: 'legacy_mixed_branch_shape', message: `Node "${entry.name}" encodes branches under both 'true'/'false' and numeric keys (${entry.numericKeys.join(', ')}). Convert to 'main' outputs only: for IF use main[0]/main[1], for Switch use main[index].`, nodeName: entry.name, details: { nodeId: node.id, type: node.type, keys: shapeKeys } }); } else if (entry.hasBoolean) { allErrors.push({ code: 'legacy_if_branch_shape', message: `Node "${entry.name}" encodes IF branches under 'true'/'false'. Use 'main' with two outputs (index 0 → true, index 1 → false). Ensure Merge nodes consume these via input indexes 0 and 1.`, nodeName: entry.name, details: { nodeId: node.id, type: node.type, keys: shapeKeys } }); } else if (entry.numericKeys.length > 0) { allErrors.push({ code: 'legacy_switch_branch_shape', message: `Node "${entry.name}" encodes Switch branches under numeric keys (${entry.numericKeys.join(', ')}). Use 'main' with outputs where index corresponds to the case: main[0], main[1], ...`, nodeName: entry.name, details: { nodeId: node.id, type: node.type, keys: entry.numericKeys } }); } } } } } catch (e) { console.warn('[WARN] Connectivity validation errored in validate_workflow:', e?.message || e); } const suggestedActions = []; try { // Build helper indexes of nodes and produced/required types const normalized = report.normalized || { nodes: {}, connectionsBySource: {}, connectionsByDestination: {} }; const nodesByName = normalized.nodes || {}; const connectionsBySource = normalized.connectionsBySource || {}; const getTypeDesc = (node) => node ? nodeTypes.getByNameAndVersion(node.type, node.typeVersion) : undefined; const producersByType = {}; for (const node of Object.values(nodesByName)) { const desc = getTypeDesc(node); const produces = ((desc?.description?.wiring?.produces) || []); for (const t of produces) { const key = String(t); producersByType[key] = producersByType[key] || []; producersByType[key].push({ name: node.name, id: node.id }); } } const addConnectSuggestion = (opts) => { suggestedActions.push({ type: 'add_connection', title: `Connect ${opts.fromName} → ${opts.toName} via ${opts.toInput}`, params: { workflow_name: params.workflow_name, source_node_id: opts.fromId || '<SOURCE_NODE_ID>', source_node_output_name: opts.fromOutput, target_node_id: opts.toId || '<TARGET_NODE_ID>', target_node_input_name: opts.toInput, target_node_input_index: 0 } }); }; const addAgentWireSuggestion = (agent, model, tools, memory) => { suggestedActions.push({ type: 'add_ai_connections', title: `Wire AI nodes to agent ${agent?.name}`, params: { workflow_name: params.workflow_name, agent_node_id: agent?.id || '<AGENT_ID>', model_node_id: model?.id, tool_node_ids: (tools || []).map(t => t.id), memory_node_id: memory?.id }, note: 'Models → ai_languageModel, Tools → ai_tool, Memory → ai_memory' }); }; // Walk through promoted warnings to craft targeted suggestions for (const issue of [...report.warnings]) { if (!issue || !issue.code) continue; const node = issue.nodeName ? nodesByName[issue.nodeName] : undefined; const desc = getTypeDesc(node); const role = desc?.description?.wiring?.role; if (issue.code === 'unconnected_node' && node) { if (role === 'vectorStore') { // Suggest connecting AiDocument (required) and AiEmbedding (optional) const docProducers = producersByType['AiDocument'] || []; if (docProducers.length > 0) { const from = docProducers[0]; addConnectSuggestion({ fromName: from.name, fromId: from.id, fromOutput: 'AiDocument', toName: node.name, toId: node.id, toInput: 'AiDocument' }); } const embProducers = producersByType['AiEmbedding'] || []; if (embProducers.length > 0) { const from = embProducers[0]; addConnectSuggestion({ fromName: from.name, fromId: from.id, fromOutput: 'AiEmbedding', toName: node.name, toId: node.id, toInput: 'AiEmbedding' }); } suggestedActions.push({ type: 'check_configuration', title: `Set Qdrant Vector Store mode`, note: 'Use mode "retrieve-as-tool" to expose the store as an AI tool for the agent.' }); } else if (role === 'agent') { // Suggest wiring model/tools/memory to agent addAgentWireSuggestion(node); } else if (node?.type?.toLowerCase()?.includes('embeddings')) { // Suggest connecting embeddings to a vector store const vectorStores = Object.values(nodesByName).filter(n => (getTypeDesc(n)?.description?.wiring?.role) === 'vectorStore'); if (vectorStores.length > 0) { const store = vectorStores[0]; addConnectSuggestion({ fromName: node.name, fromId: node.id, fromOutput: 'AiEmbedding', toName: store.name, toId: store.id, toInput: 'AiEmbedding' }); } else { suggestedActions.push({ type: 'general_advice', title: `Connect embeddings to a Vector Store`, note: 'Add a vector store (e.g., Qdrant) and connect AiEmbedding → AiEmbedding.' }); } } } if (issue.code === 'ai_node_without_ai_ports' && node) { // Likely an agent not wired via ai_* ports addAgentWireSuggestion(node); } if (issue.code === 'missing_required_input' && node && issue.details?.input) { const req = String(issue.details.input); // Try to find a producer for the required type const candidates = producersByType[req] || []; if (candidates.length > 0) { const from = candidates[0]; // Special-case agent requirements to prefer add_ai_connections if ((getTypeDesc(node)?.description?.wiring?.role) === 'agent' && req.toLowerCase() === 'ailanguagemodel') { addAgentWireSuggestion(node, { id: from.id, name: from.name }); } else { addConnectSuggestion({ fromName: from.name, fromId: from.id, fromOutput: req, toName: node.name, toId: node.id, toInput: req }); } } } } // Additional suggestion: if any node uses legacy IF boolean keys, add a general advice item try { for (const [src, byType] of Object.entries(connectionsBySource)) { const keys = Object.keys(byType || {}); const hasBoolean = keys.includes('true') || keys.includes('false'); const numericKeys = keys.filter((k) => /^\d+$/.test(k)); if (hasBoolean && numericKeys.length > 0) { suggestedActions.push({ type: 'general_advice', title: `Convert mixed IF/Switch branches on "${src}" to main outputs`, note: `Remove 'true'/'false' and numeric branch keys. Use a single 'main' outputs array: for IF use main[0] (true) and main[1] (false); for Switch map cases to main[index]. Merge inputs should match (0 ↔ input 0, 1 ↔ input 1).` }); } else if (hasBoolean) { suggestedActions.push({ type: 'general_advice', title: `Convert IF branches on "${src}" to main outputs`, note: `Replace top-level 'true'/'false' connection keys with 'main' outputs: main[0] → true, main[1] → false. When merging, wire one branch into Merge input index 0 and the other into index 1.` }); } else if (numericKeys.length > 0) { suggestedActions.push({ type: 'general_advice', title: `Convert Switch branches on "${src}" to main outputs`, note: `Replace numeric branch keys (${numericKeys.join(', ')}) with 'main' outputs array: main[0], main[1], ... in ascending order. Keep Merge inputs matched (0 ↔ input 0, 1 ↔ input 1).` }); } } } catch { /* best-effort suggestions only */ } } catch (e) { console.warn('[WARN] Failed to compute remediation suggestions in validate_workflow:', e?.message || e); } // Workflow is only OK if there are no errors after promotion and connectivity checks const validationOk = allErrors.length === 0; return { content: [{ type: "text", text: JSON.stringify({ success: validationOk, validation: { ok: validationOk, errors: allErrors, startNode: report.startNode, originalWarningCount: report.warnings.length, note: report.warnings.length > 0 ? "Warnings have been promoted to errors for validation tool" : undefined, suggestedActions, nodeIssues: report.nodeIssues } }) }] }; } catch (error) { console.error("[ERROR] Failed to validate workflow:", error); return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "Failed to validate workflow: " + error.message }) }] }; } }); // Create and configure the transport const transport = new stdio_js_1.StdioServerTransport(); // Start the server async function main() { try { // Note: loadKnownNodeBaseTypes uses resolvePath, which depends on WORKSPACE_DIR. // WORKSPACE_DIR is typically set by create_workflow. // If called before create_workflow, it might use process.cwd() or fail if workflow_nodes isn't there. // This is a known limitation for now; ideally, WORKSPACE_DIR is configured at MCP server init more globally. await (0, cache_1.loadKnownNodeBaseTypes)(); // Attempt to load node types at startup await (0, versioning_1.initializeN8nVersionSupport)(); // Initialize N8N version support const detectedVersion = await (0, versioning_1.detectN8nVersion)(); // Detect the current N8N version await (0, versioning_1.setN8nVersion)(detectedVersion || "1.30.0"); // Set the current N8N version await server.connect(transport); console.error("[DEBUG] N8N Workflow Builder MCP Server started (TypeScript version)"); // Debugging tool schemas might need update if params changed significantly for other tools const toolSchemasForDebug = { create_workflow: createWorkflowParamsSchema, list_workflows: zod_1.z.object({}), // Updated to reflect empty params get_workflow_details: getWorkflowDetailsParamsSchema, add_node: addNodeParamsSchema, edit_node: editNodeParamsSchema, delete_node: deleteNodeParamsSchema, add_connection: addConnectionParamsSchema }; const manuallyConstructedToolList = Object.entries(toolSchemasForDebug).map(([name, schema]) => { let toolDefinition = { name }; // Attempt to get description from the schema if available, or use a default. // Note: .describe() on Zod schemas is for properties, not usually the whole schema for tool description. // The description passed in the options object to server.tool() is what the MCP client sees. // This reconstruction is for local debugging of what the SDK *might* send. if (name === "create_workflow") toolDefinition.description = "Create a new n8n workflow"; else if (name === "list_workflows") toolDefinition.description = "List all n8n workflows"; else if (name === "get_workflow_details") toolDefinition.description = "Get details of a specific n8n workflow"; else if (name === "add_node") toolDefinition.description = "Add a new node to a workflow"; else if (name === "edit_node") toolDefinition.description = "Edit an existing node in a workflow"; else if (name === "delete_node") toolDefinition.description = "Delete a node from a workflow"; else if (name === "add_connection") toolDefinition.description = "Add a new connection between nodes in a workflow"; else toolDefinition.description = `Description for ${name}`; if (schema) { // This is a simplified mock of how zod-to-json-schema might convert it // Actual conversion by SDK might be more complex. const properties = {}; const required = []; const shape = schema.shape; for (const key in shape) { const field = shape[key]; properties[key] = { type: field._def.typeName.replace('Zod', '').toLowerCase(), description: field.description }; if (!field.isOptional()) { required.push(key); } } toolDefinition.inputSchema = { type: "object", properties, required }; } else { toolDefinition.inputSchema = { type: "object", properties: {}, required: [] }; } return toolDefinition; }); console.error("[DEBUG] Server's expected 'tools' array for tools/list response (with detailed inputSchemas):"); console.error(JSON.stringify(manuallyConstructedToolList, null, 2)); // Keep the process alive return new Promise((resolve, reject) => { process.on('SIGINT', () => { console.error("[DEBUG] Received SIGINT, shutting down..."); server.close().then(resolve).catch(reject); }); process.on('SIGTERM', () => { console.error("[DEBUG] Received SIGTERM, shutting down..."); server.close().then(resolve).catch(reject); }); }); } catch (error) { console.error("[ERROR] Failed to start server:", error); process.exit(1); } } main().catch(error => { console.error("[ERROR] Unhandled error in main:", error); process.exit(1); }); process.on('uncaughtException', (error) => { console.error("[ERROR] Uncaught exception:", error); // Consider whether to exit or attempt graceful shutdown }); process.on('unhandledRejection', (reason, promise) => { console.error("[ERROR] Unhandled promise rejection at:", promise, "reason:", reason); // Consider whether to exit or attempt graceful shutdown });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ifmelate/n8n-workflow-builder-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server