Skip to main content
Glama

n8n-MCP

by 88-888
node-sanitizer.tsβ€’10.2 kB
/** * Node Sanitizer Service * * Ensures nodes have complete metadata required by n8n UI. * Based on n8n AI Workflow Builder patterns: * - Merges node type defaults with user parameters * - Auto-adds required metadata for filter-based nodes (IF v2.2+, Switch v3.2+) * - Fixes operator structure * - Prevents "Could not find property option" errors */ import { INodeParameters } from 'n8n-workflow'; import { logger } from '../utils/logger'; import { WorkflowNode } from '../types/n8n-api'; /** * Sanitize a single node by adding required metadata */ export function sanitizeNode(node: WorkflowNode): WorkflowNode { const sanitized = { ...node }; // Apply node-specific sanitization if (isFilterBasedNode(node.type, node.typeVersion)) { sanitized.parameters = sanitizeFilterBasedNode( sanitized.parameters as INodeParameters, node.type, node.typeVersion ); } return sanitized; } /** * Sanitize all nodes in a workflow */ export function sanitizeWorkflowNodes(workflow: any): any { if (!workflow.nodes || !Array.isArray(workflow.nodes)) { return workflow; } return { ...workflow, nodes: workflow.nodes.map((node: any) => sanitizeNode(node)) }; } /** * Check if node is filter-based (IF v2.2+, Switch v3.2+) */ function isFilterBasedNode(nodeType: string, typeVersion: number): boolean { if (nodeType === 'n8n-nodes-base.if') { return typeVersion >= 2.2; } if (nodeType === 'n8n-nodes-base.switch') { return typeVersion >= 3.2; } return false; } /** * Sanitize filter-based nodes (IF v2.2+, Switch v3.2+) * Ensures conditions.options has complete structure */ function sanitizeFilterBasedNode( parameters: INodeParameters, nodeType: string, typeVersion: number ): INodeParameters { const sanitized = { ...parameters }; // Handle IF node if (nodeType === 'n8n-nodes-base.if' && typeVersion >= 2.2) { sanitized.conditions = sanitizeFilterConditions(sanitized.conditions as any); } // Handle Switch node if (nodeType === 'n8n-nodes-base.switch' && typeVersion >= 3.2) { if (sanitized.rules && typeof sanitized.rules === 'object') { const rules = sanitized.rules as any; if (rules.rules && Array.isArray(rules.rules)) { rules.rules = rules.rules.map((rule: any) => ({ ...rule, conditions: sanitizeFilterConditions(rule.conditions) })); } } } return sanitized; } /** * Sanitize filter conditions structure */ function sanitizeFilterConditions(conditions: any): any { if (!conditions || typeof conditions !== 'object') { return conditions; } const sanitized = { ...conditions }; // Ensure options has complete structure if (!sanitized.options) { sanitized.options = {}; } // Add required filter options metadata const requiredOptions = { version: 2, leftValue: '', caseSensitive: true, typeValidation: 'strict' }; // Merge with existing options, preserving user values sanitized.options = { ...requiredOptions, ...sanitized.options }; // Sanitize conditions array if (sanitized.conditions && Array.isArray(sanitized.conditions)) { sanitized.conditions = sanitized.conditions.map((condition: any) => sanitizeCondition(condition) ); } return sanitized; } /** * Sanitize a single condition */ function sanitizeCondition(condition: any): any { if (!condition || typeof condition !== 'object') { return condition; } const sanitized = { ...condition }; // Ensure condition has an ID if (!sanitized.id) { sanitized.id = generateConditionId(); } // Sanitize operator structure if (sanitized.operator) { sanitized.operator = sanitizeOperator(sanitized.operator); } return sanitized; } /** * Sanitize operator structure * Ensures operator has correct format: {type, operation, singleValue?} */ function sanitizeOperator(operator: any): any { if (!operator || typeof operator !== 'object') { return operator; } const sanitized = { ...operator }; // Fix common mistake: type field used for operation name // WRONG: {type: "isNotEmpty"} // RIGHT: {type: "string", operation: "isNotEmpty"} if (sanitized.type && !sanitized.operation) { // Check if type value looks like an operation (lowercase, no dots) const typeValue = sanitized.type as string; if (isOperationName(typeValue)) { logger.debug(`Fixing operator structure: converting type="${typeValue}" to operation`); // Infer data type from operation const dataType = inferDataType(typeValue); sanitized.type = dataType; sanitized.operation = typeValue; } } // Set singleValue based on operator type if (sanitized.operation) { if (isUnaryOperator(sanitized.operation)) { // Unary operators require singleValue: true sanitized.singleValue = true; } else { // Binary operators should NOT have singleValue (or it should be false/undefined) // Remove it to prevent UI errors delete sanitized.singleValue; } } return sanitized; } /** * Check if string looks like an operation name (not a data type) */ function isOperationName(value: string): boolean { // Operation names are lowercase and don't contain dots // Data types are: string, number, boolean, dateTime, array, object const dataTypes = ['string', 'number', 'boolean', 'dateTime', 'array', 'object']; return !dataTypes.includes(value) && /^[a-z][a-zA-Z]*$/.test(value); } /** * Infer data type from operation name */ function inferDataType(operation: string): string { // Boolean operations const booleanOps = ['true', 'false', 'isEmpty', 'isNotEmpty']; if (booleanOps.includes(operation)) { return 'boolean'; } // Number operations const numberOps = ['isNumeric', 'gt', 'gte', 'lt', 'lte']; if (numberOps.some(op => operation.includes(op))) { return 'number'; } // Date operations const dateOps = ['after', 'before', 'afterDate', 'beforeDate']; if (dateOps.some(op => operation.includes(op))) { return 'dateTime'; } // Default to string return 'string'; } /** * Check if operator is unary (requires singleValue: true) */ function isUnaryOperator(operation: string): boolean { const unaryOps = [ 'isEmpty', 'isNotEmpty', 'true', 'false', 'isNumeric' ]; return unaryOps.includes(operation); } /** * Generate unique condition ID */ function generateConditionId(): string { return `condition-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } /** * Validate that a node has complete metadata * Returns array of issues found */ export function validateNodeMetadata(node: WorkflowNode): string[] { const issues: string[] = []; if (!isFilterBasedNode(node.type, node.typeVersion)) { return issues; // Not a filter-based node } // Check IF node if (node.type === 'n8n-nodes-base.if') { const conditions = (node.parameters.conditions as any); if (!conditions?.options) { issues.push('Missing conditions.options'); } else { const required = ['version', 'leftValue', 'typeValidation', 'caseSensitive']; for (const field of required) { if (!(field in conditions.options)) { issues.push(`Missing conditions.options.${field}`); } } } // Check operators if (conditions?.conditions && Array.isArray(conditions.conditions)) { for (let i = 0; i < conditions.conditions.length; i++) { const condition = conditions.conditions[i]; const operatorIssues = validateOperator(condition.operator, `conditions.conditions[${i}].operator`); issues.push(...operatorIssues); } } } // Check Switch node if (node.type === 'n8n-nodes-base.switch') { const rules = (node.parameters.rules as any); if (rules?.rules && Array.isArray(rules.rules)) { for (let i = 0; i < rules.rules.length; i++) { const rule = rules.rules[i]; if (!rule.conditions?.options) { issues.push(`Missing rules.rules[${i}].conditions.options`); } else { const required = ['version', 'leftValue', 'typeValidation', 'caseSensitive']; for (const field of required) { if (!(field in rule.conditions.options)) { issues.push(`Missing rules.rules[${i}].conditions.options.${field}`); } } } // Check operators if (rule.conditions?.conditions && Array.isArray(rule.conditions.conditions)) { for (let j = 0; j < rule.conditions.conditions.length; j++) { const condition = rule.conditions.conditions[j]; const operatorIssues = validateOperator( condition.operator, `rules.rules[${i}].conditions.conditions[${j}].operator` ); issues.push(...operatorIssues); } } } } } return issues; } /** * Validate operator structure */ function validateOperator(operator: any, path: string): string[] { const issues: string[] = []; if (!operator || typeof operator !== 'object') { issues.push(`${path}: operator is missing or not an object`); return issues; } if (!operator.type) { issues.push(`${path}: missing required field 'type'`); } else if (!['string', 'number', 'boolean', 'dateTime', 'array', 'object'].includes(operator.type)) { issues.push(`${path}: invalid type "${operator.type}" (must be data type, not operation)`); } if (!operator.operation) { issues.push(`${path}: missing required field 'operation'`); } // Check singleValue based on operator type if (operator.operation) { if (isUnaryOperator(operator.operation)) { // Unary operators MUST have singleValue: true if (operator.singleValue !== true) { issues.push(`${path}: unary operator "${operator.operation}" requires singleValue: true`); } } else { // Binary operators should NOT have singleValue if (operator.singleValue === true) { issues.push(`${path}: binary operator "${operator.operation}" should not have singleValue: true (only unary operators need this)`); } } } return issues; }

MCP directory API

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

curl -X GET 'https://glama.ai/api/mcp/v1/servers/88-888/n8n-mcp'

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