index.js•146 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
});