Skip to content

Commit 44a1da5

Browse files
czlonkowskiclaude
andcommitted
fix: load .node.ee.js enterprise nodes — restore Evaluation Trigger to the database (#937)
The loader's node-name regex only matched .node.js/.node.ts, so EvaluationTrigger.node.ee.js produced a garbage node name, and the export-resolution fallback took the module's first export — the numeric constant DEFAULT_STARTING_ROW, not the node class — so parsing failed and nodes-base.evaluationTrigger was silently dropped from every rebuild. Agents could not discover it and validate_workflow falsely rejected valid evaluation workflows with "Unknown node type". - Accept an optional .ee segment in the node-name regex - Resolve the node class only from function-typed exports (shared resolveNodeClass helper for both manifest formats) - Add nodes-base.evaluation and nodes-base.evaluationTrigger to the canonical core-node completeness gate so a silent drop fails the build - Add fixture-based regression tests exercising the real loader - Rebuild the node database: 827 core nodes (677 n8n-nodes-base + 150 @n8n/n8n-nodes-langchain) Fixes #937 Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 62f635b commit 44a1da5

9 files changed

Lines changed: 172 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [2.65.1] - 2026-07-16
11+
12+
### Fixed
13+
14+
- **Evaluation Trigger restored to the node database (#937).** `nodes-base.evaluationTrigger` was silently dropped during every database rebuild: the loader's node-name regex did not match n8n's enterprise `.node.ee.js` file suffix, and the export-resolution fallback picked the module's first export — for EvaluationTrigger a numeric constant, not the node class — so parsing failed and the node never reached the database. Agents could not discover the node via `search_nodes`/`get_node`, and `validate_workflow` falsely rejected valid evaluation workflows with "Unknown node type: n8n-nodes-base.evaluationTrigger". The regex now accepts the `.ee` segment, the fallback only ever resolves function-typed exports, and both `nodes-base.evaluation` and `nodes-base.evaluationTrigger` joined the canonical core-node completeness gate so a future silent drop fails the rebuild loudly. Rebuilt database: 827 core nodes (677 from `n8n-nodes-base` + 150 from `@n8n/n8n-nodes-langchain`).
15+
1016
## [2.65.0] - 2026-07-15
1117

1218
### Added

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@
99
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fczlonkowski%2Fn8n--mcp-green.svg)](https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp)
1010
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/n8n-mcp?referralCode=n8n-mcp)
1111

12-
A Model Context Protocol (MCP) server that provides AI assistants with comprehensive access to n8n node documentation, properties, and operations. Deploy in minutes to give Claude and other AI assistants deep knowledge about n8n's 2,174 workflow automation nodes (826 core + 1,348 community).
12+
A Model Context Protocol (MCP) server that provides AI assistants with comprehensive access to n8n node documentation, properties, and operations. Deploy in minutes to give Claude and other AI assistants deep knowledge about n8n's 2,175 workflow automation nodes (827 core + 1,348 community).
1313

1414
## Overview
1515

1616
n8n-MCP serves as a bridge between n8n's workflow automation platform and AI models, enabling them to understand and work with n8n nodes effectively. It provides structured access to:
1717

18-
- **2,174 n8n nodes** - 826 core nodes + 1,348 community nodes (1,195 verified)
18+
- **2,175 n8n nodes** - 827 core nodes + 1,348 community nodes (1,195 verified)
1919
- **Node properties** - 99% coverage with detailed schemas
2020
- **Node operations** - 63.6% coverage of available actions
2121
- **Documentation** - 87% coverage from official n8n docs (including AI nodes)

data/nodes.db

0 Bytes
Binary file not shown.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "n8n-mcp",
3-
"version": "2.65.0",
3+
"version": "2.65.1",
44
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

src/loaders/node-loader.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,21 @@ export class N8nNodeLoader {
126126
}
127127
}
128128

129+
/**
130+
* Resolve the node class from a module's exports: default export, then the
131+
* export matching the node name, then the first function-typed export. The
132+
* last step must skip non-function exports — EvaluationTrigger.node.ee.js
133+
* exports the constant DEFAULT_STARTING_ROW before the class, and picking
134+
* it silently dropped the node from the database (#937).
135+
*/
136+
private resolveNodeClass(nodeModule: any, nodeName: string): any {
137+
return (
138+
nodeModule.default ||
139+
nodeModule[nodeName] ||
140+
Object.values(nodeModule).find(exported => typeof exported === 'function')
141+
);
142+
}
143+
129144
private async loadPackageNodes(packageName: string, packagePath: string, packageJson: any): Promise<LoadedNode[]> {
130145
const n8nConfig = packageJson.n8n || {};
131146
const nodes: LoadedNode[] = [];
@@ -142,12 +157,14 @@ export class N8nNodeLoader {
142157
const fullPath = path.join(packageDir, nodePath);
143158
const nodeModule = this.loadNodeModule(fullPath);
144159

145-
// Extract node name from path (e.g., "dist/nodes/Slack/Slack.node.js" -> "Slack")
146-
const nodeNameMatch = nodePath.match(/\/([^\/]+)\.node\.(js|ts)$/);
147-
const nodeName = nodeNameMatch ? nodeNameMatch[1] : path.basename(nodePath, '.node.js');
160+
// Extract node name from path, including enterprise ".node.ee.js"
161+
// files (e.g. "dist/nodes/Evaluation/EvaluationTrigger/EvaluationTrigger.node.ee.js" -> "EvaluationTrigger")
162+
const nodeNameMatch = nodePath.match(/\/([^\/]+)\.node(?:\.ee)?\.(js|ts)$/);
163+
const nodeName = nodeNameMatch
164+
? nodeNameMatch[1]
165+
: path.basename(nodePath).replace(/\.node(?:\.ee)?\.(js|ts)$/, '');
148166

149-
// Handle default export and various export patterns
150-
const NodeClass = nodeModule.default || nodeModule[nodeName] || Object.values(nodeModule)[0];
167+
const NodeClass = this.resolveNodeClass(nodeModule, nodeName);
151168
if (NodeClass) {
152169
nodes.push({ packageName, nodeName, NodeClass });
153170
console.log(` ✓ Loaded ${nodeName} from ${packageName}`);
@@ -165,8 +182,7 @@ export class N8nNodeLoader {
165182
const fullPath = path.join(packageDir, nodePath as string);
166183
const nodeModule = this.loadNodeModule(fullPath);
167184

168-
// Handle default export and various export patterns
169-
const NodeClass = nodeModule.default || nodeModule[nodeName] || Object.values(nodeModule)[0];
185+
const NodeClass = this.resolveNodeClass(nodeModule, nodeName);
170186
if (NodeClass) {
171187
nodes.push({ packageName, nodeName, NodeClass });
172188
console.log(` ✓ Loaded ${nodeName} from ${packageName}`);

src/scripts/core-node-check.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
export const CANONICAL_CORE_NODES: readonly string[] = [
1010
'nodes-base.code',
1111
'nodes-base.convertToFile',
12+
'nodes-base.evaluation',
13+
'nodes-base.evaluationTrigger',
1214
'nodes-base.executeWorkflow',
1315
'nodes-base.extractFromFile',
1416
'nodes-base.httpRequest',
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach, MockInstance } from 'vitest';
2+
import * as fs from 'fs';
3+
import * as os from 'os';
4+
import * as path from 'path';
5+
import { N8nNodeLoader } from '@/loaders/node-loader';
6+
7+
/**
8+
* Regression tests for issue #937: nodes-base.evaluationTrigger was silently
9+
* dropped from the database. Two compounding causes:
10+
*
11+
* 1. The node-name regex only matched `.node.js`/`.node.ts`, so enterprise
12+
* `.node.ee.js` paths produced a garbage node name and the named-export
13+
* lookup missed.
14+
* 2. The export fallback took the FIRST module export, which for
15+
* EvaluationTrigger is the constant DEFAULT_STARTING_ROW (a number), not
16+
* the node class — so a number was passed downstream and parsing failed.
17+
*
18+
* These tests exercise the REAL loader against fixture packages on disk.
19+
*/
20+
describe('N8nNodeLoader enterprise (.node.ee.js) modules', () => {
21+
let fixtureDir: string;
22+
let consoleLogSpy: MockInstance;
23+
let consoleErrorSpy: MockInstance;
24+
let consoleWarnSpy: MockInstance;
25+
26+
const writeFixture = (relPath: string, content: string) => {
27+
const fullPath = path.join(fixtureDir, relPath);
28+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
29+
fs.writeFileSync(fullPath, content);
30+
};
31+
32+
beforeAll(() => {
33+
fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'n8n-mcp-loader-ee-fixture-'));
34+
35+
writeFixture('package.json', JSON.stringify({ name: 'fixture-pkg', version: '1.0.0' }));
36+
37+
// Mirrors n8n-nodes-base EvaluationTrigger.node.ee.js: a non-class export
38+
// listed BEFORE the node class.
39+
writeFixture(
40+
'dist/nodes/Evaluation/EvaluationTrigger/EvaluationTrigger.node.ee.js',
41+
`const DEFAULT_STARTING_ROW = 2;
42+
class EvaluationTrigger {
43+
constructor() {
44+
this.description = { name: 'evaluationTrigger', displayName: 'Evaluation Trigger', properties: [] };
45+
}
46+
}
47+
module.exports = { DEFAULT_STARTING_ROW, EvaluationTrigger };`
48+
);
49+
50+
// Mirrors n8n-nodes-base Evaluation.node.ee.js: single class export.
51+
writeFixture(
52+
'dist/nodes/Evaluation/Evaluation/Evaluation.node.ee.js',
53+
`class Evaluation {
54+
constructor() {
55+
this.description = { name: 'evaluation', displayName: 'Evaluation', properties: [] };
56+
}
57+
}
58+
module.exports = { Evaluation };`
59+
);
60+
61+
// A module whose export name matches neither the file name nor a default
62+
// export, with a non-class export first: the fallback must still resolve
63+
// the class, never a constant.
64+
writeFixture(
65+
'dist/nodes/Renamed/Renamed.node.js',
66+
`const SOME_CONSTANT = 42;
67+
class InternalName {
68+
constructor() {
69+
this.description = { name: 'renamed', displayName: 'Renamed', properties: [] };
70+
}
71+
}
72+
module.exports = { SOME_CONSTANT, InternalName };`
73+
);
74+
});
75+
76+
afterAll(() => {
77+
fs.rmSync(fixtureDir, { recursive: true, force: true });
78+
});
79+
80+
beforeEach(() => {
81+
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
82+
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
83+
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
84+
});
85+
86+
afterEach(() => {
87+
consoleLogSpy.mockRestore();
88+
consoleErrorSpy.mockRestore();
89+
consoleWarnSpy.mockRestore();
90+
});
91+
92+
const loadFixturePackage = async (nodePaths: string[]) => {
93+
const loader = new N8nNodeLoader();
94+
const packageJson = { n8n: { nodes: nodePaths } };
95+
return (loader as any).loadPackageNodes('fixture-pkg', fixtureDir, packageJson);
96+
};
97+
98+
it('extracts the node name from a .node.ee.js path and resolves the class by name', async () => {
99+
const results = await loadFixturePackage([
100+
'dist/nodes/Evaluation/EvaluationTrigger/EvaluationTrigger.node.ee.js'
101+
]);
102+
103+
expect(results).toHaveLength(1);
104+
expect(results[0].nodeName).toBe('EvaluationTrigger');
105+
expect(typeof results[0].NodeClass).toBe('function');
106+
const instance = new results[0].NodeClass();
107+
expect(instance.description.name).toBe('evaluationTrigger');
108+
});
109+
110+
it('loads a single-export .node.ee.js module', async () => {
111+
const results = await loadFixturePackage([
112+
'dist/nodes/Evaluation/Evaluation/Evaluation.node.ee.js'
113+
]);
114+
115+
expect(results).toHaveLength(1);
116+
expect(results[0].nodeName).toBe('Evaluation');
117+
const instance = new results[0].NodeClass();
118+
expect(instance.description.name).toBe('evaluation');
119+
});
120+
121+
it('never resolves a non-class export as the node class', async () => {
122+
const results = await loadFixturePackage(['dist/nodes/Renamed/Renamed.node.js']);
123+
124+
expect(results).toHaveLength(1);
125+
expect(results[0].nodeName).toBe('Renamed');
126+
expect(typeof results[0].NodeClass).toBe('function');
127+
const instance = new results[0].NodeClass();
128+
expect(instance.description.name).toBe('renamed');
129+
});
130+
});

tests/unit/loaders/node-loader.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,10 @@ describe('N8nNodeLoader', () => {
8888
const fullPath = mockRequireResolve(`${packagePath}/${nodePath}`);
8989
const nodeModule = mockRequire(fullPath);
9090

91-
const nodeNameMatch = nodePath.match(/\/([^\/]+)\.node\.(js|ts)$/);
92-
const nodeName = nodeNameMatch ? nodeNameMatch[1] : nodePath.replace(/.*\//, '').replace(/\.node\.(js|ts)$/, '');
93-
94-
const NodeClass = nodeModule.default || nodeModule[nodeName] || Object.values(nodeModule)[0];
91+
const nodeNameMatch = nodePath.match(/\/([^\/]+)\.node(?:\.ee)?\.(js|ts)$/);
92+
const nodeName = nodeNameMatch ? nodeNameMatch[1] : nodePath.replace(/.*\//, '').replace(/\.node(?:\.ee)?\.(js|ts)$/, '');
93+
94+
const NodeClass = nodeModule.default || nodeModule[nodeName] || Object.values(nodeModule).find((v: any) => typeof v === 'function');
9595
if (NodeClass) {
9696
nodes.push({ packageName, nodeName, NodeClass });
9797
console.log(` ✓ Loaded ${nodeName} from ${packageName}`);
@@ -107,8 +107,8 @@ describe('N8nNodeLoader', () => {
107107
try {
108108
const fullPath = mockRequireResolve(`${packagePath}/${nodePath as string}`);
109109
const nodeModule = mockRequire(fullPath);
110-
111-
const NodeClass = nodeModule.default || nodeModule[nodeName] || Object.values(nodeModule)[0];
110+
111+
const NodeClass = nodeModule.default || nodeModule[nodeName] || Object.values(nodeModule).find((v: any) => typeof v === 'function');
112112
if (NodeClass) {
113113
nodes.push({ packageName, nodeName, NodeClass });
114114
console.log(` ✓ Loaded ${nodeName} from ${packageName}`);

0 commit comments

Comments
 (0)