Skip to main content
Glama

n8n-workflow-builder-mcp

by ifmelate
workflow-validator-from-n8n.ts17.7 kB
// Self-contained validator for n8n workflow JSON, with no imports from the n8n repo export type NodeConnectionType = string; export interface IConnection { node: string; type: NodeConnectionType; index: number; } export type IConnections = Record<string, Record<string, Array<Array<IConnection | undefined> | undefined>>>; export interface INodeParameters { [key: string]: unknown; } export interface INode { id?: string; name: string; type: string; typeVersion?: number; position?: [number, number]; parameters: INodeParameters; disabled?: boolean; // optional map of credential name -> credential payload/id credentials?: Record<string, unknown>; } export interface IWorkflowSettings { timezone?: string; [key: string]: unknown; } export interface INodePropertyDescription { name: string; displayName?: string; type?: string; default?: unknown; required?: boolean; displayOptions?: unknown; options?: Array<{ name: string; value: unknown }>; typeOptions?: Record<string, unknown>; } export interface INodeTypeDescription { name?: string; properties: INodePropertyDescription[]; } export interface INodeType { description: INodeTypeDescription; } export interface INodeTypes { getByNameAndVersion(name: string, version?: number): INodeType | undefined; } export type ValidationError = { code: string; message: string; nodeName?: string; details?: unknown; }; export type ValidationWarning = { code: string; message: string; nodeName?: string; details?: unknown; }; export type ValidationReport = { ok: boolean; errors: ValidationError[]; warnings: ValidationWarning[]; normalized: { nodes: Record<string, INode>; connectionsBySource: IConnections; connectionsByDestination: IConnections; }; startNode?: string; nodeIssues?: Record<string, NodeValidationIssue[]>; }; export type ImportedWorkflowJson = { id?: string; name?: string; nodes?: INode[]; connections?: IConnections; settings?: IWorkflowSettings; pinData?: Record<string, unknown>; }; function isRecord(value: unknown): value is Record<string, unknown> { return !!value && typeof value === 'object' && !Array.isArray(value); } function assertBasicShape(input: unknown): asserts input is ImportedWorkflowJson { if (!isRecord(input)) throw new Error('Workflow must be an object'); if (!Array.isArray(input.nodes)) throw new Error('Workflow.nodes must be an array'); if (!isRecord(input.connections)) throw new Error('Workflow.connections must be an object'); } function mapConnectionsByDestination(source: IConnections): IConnections { const dest: IConnections = {}; for (const [src, byType] of Object.entries(source)) { for (const [type, groups] of Object.entries(byType)) { (groups || []).forEach((group, inputIndex) => { (group || []).forEach((conn) => { if (!conn) return; const target = conn.node; dest[target] = dest[target] || {}; dest[target][type] = dest[target][type] || []; const arr = dest[target][type]!; while (arr.length <= conn.index) arr.push([]); arr[conn.index] = arr[conn.index] || []; arr[conn.index]!.push({ node: src, type: type as NodeConnectionType, index: inputIndex }); }); }); } } return dest; } function applyDefaultParameters(node: INode, nodeType: INodeType) { const defaults = nodeType.description?.properties || []; for (const prop of defaults) { const name = prop.name; if (!(name in (node.parameters || {})) && Object.prototype.hasOwnProperty.call(prop, 'default')) { node.parameters[name] = prop.default; } } } function isEmpty(val: unknown): boolean { if (val === undefined || val === null) return true; if (typeof val === 'string') return val.trim() === ''; if (Array.isArray(val)) return val.length === 0; if (typeof val === 'object') return Object.keys(val as object).length === 0; return false; } type DisplayOptions = { show?: Record<string, unknown>; hide?: Record<string, unknown> }; function evaluateDisplayOptions(displayOptions: unknown, params: INodeParameters): boolean { const opts = (displayOptions || {}) as DisplayOptions; const toArray = (v: unknown) => (Array.isArray(v) ? v : [v]); // show: all keys must match at least one of provided values if (opts.show) { for (const [key, expected] of Object.entries(opts.show)) { const val = (params as any)[key]; const list = toArray(expected).map((x) => (typeof x === 'object' && x !== null ? JSON.stringify(x) : x)); const match = toArray(val).some((v) => list.includes(typeof v === 'object' && v !== null ? JSON.stringify(v) : v)); if (!match) return false; } } // hide: if any key matches, the field is hidden if (opts.hide) { for (const [key, expected] of Object.entries(opts.hide)) { const val = (params as any)[key]; const list = toArray(expected).map((x) => (typeof x === 'object' && x !== null ? JSON.stringify(x) : x)); const match = toArray(val).some((v) => list.includes(typeof v === 'object' && v !== null ? JSON.stringify(v) : v)); if (match) return false; } } return true; } export type NodeValidationIssue = { code: string; message: string; property?: string; }; export function validateNodeAgainstDefinition(node: INode, nodeType: INodeType): { ok: boolean; issues: NodeValidationIssue[] } { const issues: NodeValidationIssue[] = []; const properties = nodeType.description?.properties || []; // required parameters (respect simple displayOptions) for (const prop of properties) { if (!prop.required) continue; const visible = evaluateDisplayOptions(prop.displayOptions, node.parameters || {}); if (!visible) continue; const value = (node.parameters || {})[prop.name]; if (isEmpty(value)) { issues.push({ code: 'missing_parameter', message: `Parameter "${prop.displayName || prop.name}" is required.`, property: prop.name }); } } // fixedCollection option validation (prevents "Could not find property option") for (const prop of properties) { if (prop.type !== 'fixedCollection') continue; const visible = evaluateDisplayOptions(prop.displayOptions, node.parameters || {}); if (!visible) continue; const value = (node.parameters || {})[prop.name] as Record<string, unknown> | undefined; if (value === undefined) continue; const allowed = new Set((prop.options || []).map((o) => o.name)); const multiple = !!(prop.typeOptions && (prop.typeOptions as any).multipleValues); if (multiple) { if (!isRecord(value)) { issues.push({ code: 'invalid_fixed_collection_shape', message: `"${prop.displayName || prop.name}" must be an object with arrays per option.`, property: prop.name }); } else { for (const k of Object.keys(value)) { if (!allowed.has(k)) { issues.push({ code: 'unknown_fixed_collection_option', message: `Unknown option "${k}" in "${prop.displayName || prop.name}"`, property: prop.name }); continue; } const arr = (value as any)[k]; if (!Array.isArray(arr)) { issues.push({ code: 'invalid_fixed_collection_shape', message: `Option "${k}" in "${prop.displayName || prop.name}" must be an array.`, property: prop.name }); } } // optional min/max fields count const min = (prop.typeOptions as any)?.minRequiredFields; const max = (prop.typeOptions as any)?.maxAllowedFields; const total = Object.keys(value).reduce((sum, k) => sum + (Array.isArray((value as any)[k]) ? (value as any)[k].length : 0), 0); if (typeof min === 'number' && total < min) { issues.push({ code: 'fixed_collection_min_fields', message: `At least ${min} ${min === 1 ? 'field is' : 'fields are'} required.`, property: prop.name }); } if (typeof max === 'number' && total > max) { issues.push({ code: 'fixed_collection_max_fields', message: `At most ${max} ${max === 1 ? 'field is' : 'fields are'} allowed.`, property: prop.name }); } } } else { if (!isRecord(value)) { issues.push({ code: 'invalid_fixed_collection_shape', message: `"${prop.displayName || prop.name}" must be an object with exactly one option.`, property: prop.name }); } else { const keys = Object.keys(value); for (const k of keys) { if (!allowed.has(k)) { issues.push({ code: 'unknown_fixed_collection_option', message: `Unknown option "${k}" in "${prop.displayName || prop.name}"`, property: prop.name }); } } if (keys.length > 1) { issues.push({ code: 'fixed_collection_multiple_selected', message: `Only one option can be selected in "${prop.displayName || prop.name}".`, property: prop.name }); } const min = (prop.typeOptions as any)?.minRequiredFields; const max = (prop.typeOptions as any)?.maxAllowedFields; const total = keys.length; if (typeof min === 'number' && total < min) { issues.push({ code: 'fixed_collection_min_fields', message: `At least ${min} ${min === 1 ? 'field is' : 'fields are'} required.`, property: prop.name }); } if (typeof max === 'number' && total > max) { issues.push({ code: 'fixed_collection_max_fields', message: `At most ${max} ${max === 1 ? 'field is' : 'fields are'} allowed.`, property: prop.name }); } } } } // credentials required // We look into a conventional credential requirement: nodeType.description may have no credentials in scraped JSON. // If your node_definitions contain credentialsConfig: [{ name, required }], prefer that. const anyDesc = nodeType.description as unknown as { credentialsConfig?: Array<{ name: string; required?: boolean }> }; if (Array.isArray(anyDesc?.credentialsConfig)) { for (const cred of anyDesc.credentialsConfig) { if (cred.required) { const has = !!(node.credentials && Object.prototype.hasOwnProperty.call(node.credentials, cred.name)); if (!has) issues.push({ code: 'missing_credentials', message: `Credentials for ${cred.name} are not set.` }); } } } return { ok: issues.length === 0, issues }; } export function validateAndNormalizeWorkflow( raw: unknown, nodeTypes: INodeTypes, ): ValidationReport { const errors: ValidationError[] = []; const warnings: ValidationWarning[] = []; try { assertBasicShape(raw); } catch (e) { return { ok: false, errors: [ { code: 'invalid_shape', message: e instanceof Error ? e.message : 'Invalid workflow JSON', }, ], warnings, normalized: { nodes: {}, connectionsBySource: {}, connectionsByDestination: {}, }, }; } const json = raw as ImportedWorkflowJson; // Build nodes map and apply defaults where possible const nodesByName: Record<string, INode> = {}; const nodeIssues: Record<string, NodeValidationIssue[]> = {}; for (const node of (json.nodes as INode[]) ?? []) { nodesByName[node.name] = { ...node, parameters: { ...(node.parameters || {}) } }; const nodeType = nodeTypes.getByNameAndVersion(node.type, node.typeVersion); if (!nodeType) { errors.push({ code: 'unknown_node_type', message: `Unknown node type ${node.type}@${node.typeVersion} for node "${node.name}"`, nodeName: node.name, details: { type: node.type, version: node.typeVersion }, }); } else { applyDefaultParameters(nodesByName[node.name], nodeType); const v = validateNodeAgainstDefinition(nodesByName[node.name], nodeType); if (!v.ok) nodeIssues[node.name] = v.issues; } } const connectionsBySource: IConnections = (json.connections as IConnections) ?? {}; const connectionsByDestination: IConnections = mapConnectionsByDestination(connectionsBySource); // Validate connections reference existing nodes for (const [sourceName, byType] of Object.entries(connectionsBySource)) { if (!nodesByName[sourceName]) { errors.push({ code: 'unknown_source_node', message: `Connection from unknown source node "${sourceName}"`, nodeName: sourceName, }); continue; } for (const groups of Object.values(byType)) { (groups || []).forEach((group) => { (group || []).forEach((conn) => { if (!conn) return; if (!nodesByName[conn.node]) { errors.push({ code: 'unknown_target_node', message: `Connection to unknown target node "${conn.node}" from "${sourceName}"`, nodeName: conn.node, }); } }); }); } } // Validate pinData node references if (isRecord(json.pinData)) { for (const nodeName of Object.keys(json.pinData)) { if (!nodesByName[nodeName]) { warnings.push({ code: 'pin_for_unknown_node', message: `pinData references unknown node "${nodeName}"`, nodeName, }); } } } // Infer a simple start node: first node with no incoming 'main' connection let startNode: string | undefined; const incomingMain = new Set<string>(Object.keys(connectionsByDestination).filter((k) => (connectionsByDestination[k] || {}).hasOwnProperty('main'))); for (const name of Object.keys(nodesByName)) { if (!incomingMain.has(name) && nodesByName[name].disabled !== true) { startNode = name; break; } } if (!startNode) { // fallback to first node if any startNode = Object.keys(nodesByName)[0]; } if (!startNode) { warnings.push({ code: 'no_start_node', message: 'No start node inferred for workflow' }); } return { ok: errors.length === 0, errors, warnings, normalized: { nodes: nodesByName, connectionsBySource, connectionsByDestination, }, startNode, nodeIssues: Object.keys(nodeIssues).length ? nodeIssues : undefined, }; } // Convenience: minimal in-memory node types registry for tests or external callers export class SimpleNodeTypes implements INodeTypes { private readonly registry: Map<string, INodeType> = new Map(); register(name: string, version: number | number[] | undefined, description: INodeTypeDescription) { const versions = Array.isArray(version) ? version : version !== undefined ? [version] : [1]; const nodeType: INodeType = { description }; for (const v of versions) { this.registry.set(`${name}@${v}`, nodeType); } } getByNameAndVersion(name: string, version?: number): INodeType | undefined { if (version !== undefined) return this.registry.get(`${name}@${version}`); const candidates = [...this.registry.keys()] .filter((k) => k.startsWith(`${name}@`)) .sort() .reverse(); const key = candidates[0]; return key ? this.registry.get(key) : undefined; } } // Example runner (optional): // ts-node tools/workflowValidator.ts /path/to/workflow.json // eslint-disable-next-line @typescript-eslint/no-explicit-any declare const require: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any declare const module: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any declare const process: any; if (typeof require !== 'undefined' && typeof module !== 'undefined' && require.main === module) { // eslint-disable-next-line @typescript-eslint/no-var-requires const fs = require('fs'); const file = process.argv[2]; if (!file) { console.error('Usage: ts-node tools/workflowValidator.ts <workflow.json>'); process.exit(2); } const json = JSON.parse(fs.readFileSync(file, 'utf8')) as ImportedWorkflowJson; const nodeTypes: INodeTypes = { getByNameAndVersion: () => undefined }; const report = validateAndNormalizeWorkflow(json, nodeTypes); console.log(JSON.stringify({ ok: report.ok, errors: report.errors, warnings: report.warnings, startNode: report.startNode }, null, 2)); }

MCP directory API

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

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

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