@pagelines/n8n-mcp 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -95,6 +95,89 @@ describe('validateWorkflow', () => {
95
95
  severity: 'warning',
96
96
  }));
97
97
  });
98
+ it('info on code node usage', () => {
99
+ const workflow = createWorkflow({
100
+ nodes: [
101
+ {
102
+ id: '1',
103
+ name: 'my_code',
104
+ type: 'n8n-nodes-base.code',
105
+ typeVersion: 1,
106
+ position: [0, 0],
107
+ parameters: { jsCode: 'return items;' },
108
+ },
109
+ ],
110
+ });
111
+ const result = validateWorkflow(workflow);
112
+ expect(result.warnings).toContainEqual(expect.objectContaining({
113
+ rule: 'code_node_usage',
114
+ severity: 'info',
115
+ }));
116
+ });
117
+ it('warns on AI node without structured output settings', () => {
118
+ const workflow = createWorkflow({
119
+ nodes: [
120
+ {
121
+ id: '1',
122
+ name: 'ai_agent',
123
+ type: '@n8n/n8n-nodes-langchain.agent',
124
+ typeVersion: 1,
125
+ position: [0, 0],
126
+ parameters: {
127
+ outputParser: true,
128
+ schemaType: 'manual',
129
+ // Missing promptType: 'define' and hasOutputParser: true
130
+ },
131
+ },
132
+ ],
133
+ });
134
+ const result = validateWorkflow(workflow);
135
+ expect(result.warnings).toContainEqual(expect.objectContaining({
136
+ rule: 'ai_structured_output',
137
+ severity: 'warning',
138
+ }));
139
+ });
140
+ it('passes AI node with correct structured output settings', () => {
141
+ const workflow = createWorkflow({
142
+ nodes: [
143
+ {
144
+ id: '1',
145
+ name: 'ai_agent',
146
+ type: '@n8n/n8n-nodes-langchain.agent',
147
+ typeVersion: 1,
148
+ position: [0, 0],
149
+ parameters: {
150
+ outputParser: true,
151
+ schemaType: 'manual',
152
+ promptType: 'define',
153
+ hasOutputParser: true,
154
+ },
155
+ },
156
+ ],
157
+ });
158
+ const result = validateWorkflow(workflow);
159
+ const aiWarnings = result.warnings.filter((w) => w.rule === 'ai_structured_output');
160
+ expect(aiWarnings).toHaveLength(0);
161
+ });
162
+ it('warns on in-memory storage nodes', () => {
163
+ const workflow = createWorkflow({
164
+ nodes: [
165
+ {
166
+ id: '1',
167
+ name: 'memory_buffer',
168
+ type: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
169
+ typeVersion: 1,
170
+ position: [0, 0],
171
+ parameters: {},
172
+ },
173
+ ],
174
+ });
175
+ const result = validateWorkflow(workflow);
176
+ expect(result.warnings).toContainEqual(expect.objectContaining({
177
+ rule: 'in_memory_storage',
178
+ severity: 'warning',
179
+ }));
180
+ });
98
181
  });
99
182
  describe('validatePartialUpdate', () => {
100
183
  it('errors when node not found', () => {
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Local version control for n8n workflows
3
+ * Stores workflow snapshots in ~/.n8n-mcp/versions/
4
+ */
5
+ import type { N8nWorkflow } from './types.js';
6
+ export interface WorkflowVersion {
7
+ id: string;
8
+ workflowId: string;
9
+ workflowName: string;
10
+ timestamp: string;
11
+ reason: string;
12
+ nodeCount: number;
13
+ hash: string;
14
+ }
15
+ export interface VersionConfig {
16
+ enabled: boolean;
17
+ maxVersions: number;
18
+ storageDir: string;
19
+ }
20
+ /**
21
+ * Initialize version control with custom config
22
+ */
23
+ export declare function initVersionControl(customConfig?: Partial<VersionConfig>): void;
24
+ /**
25
+ * Save a workflow version
26
+ */
27
+ export declare function saveVersion(workflow: N8nWorkflow, reason?: string): Promise<WorkflowVersion | null>;
28
+ /**
29
+ * List all versions for a workflow
30
+ */
31
+ export declare function listVersions(workflowId: string): Promise<WorkflowVersion[]>;
32
+ /**
33
+ * Get a specific version's full workflow data
34
+ */
35
+ export declare function getVersion(workflowId: string, versionId: string): Promise<{
36
+ meta: WorkflowVersion;
37
+ workflow: N8nWorkflow;
38
+ } | null>;
39
+ /**
40
+ * Get the most recent version
41
+ */
42
+ export declare function getLatestVersion(workflowId: string): Promise<{
43
+ meta: WorkflowVersion;
44
+ workflow: N8nWorkflow;
45
+ } | null>;
46
+ /**
47
+ * Compare two workflow versions
48
+ */
49
+ export interface VersionDiff {
50
+ nodesAdded: string[];
51
+ nodesRemoved: string[];
52
+ nodesModified: string[];
53
+ connectionsChanged: boolean;
54
+ settingsChanged: boolean;
55
+ summary: string;
56
+ }
57
+ export declare function diffWorkflows(oldWorkflow: N8nWorkflow, newWorkflow: N8nWorkflow): VersionDiff;
58
+ /**
59
+ * Delete all versions for a workflow
60
+ */
61
+ export declare function deleteAllVersions(workflowId: string): Promise<number>;
62
+ /**
63
+ * Get version control status/stats
64
+ */
65
+ export declare function getVersionStats(): Promise<{
66
+ enabled: boolean;
67
+ storageDir: string;
68
+ maxVersions: number;
69
+ workflowCount: number;
70
+ totalVersions: number;
71
+ }>;
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Local version control for n8n workflows
3
+ * Stores workflow snapshots in ~/.n8n-mcp/versions/
4
+ */
5
+ import { promises as fs } from 'fs';
6
+ import * as path from 'path';
7
+ import * as os from 'os';
8
+ const DEFAULT_CONFIG = {
9
+ enabled: true,
10
+ maxVersions: 20,
11
+ storageDir: path.join(os.homedir(), '.n8n-mcp', 'versions'),
12
+ };
13
+ let config = { ...DEFAULT_CONFIG };
14
+ /**
15
+ * Initialize version control with custom config
16
+ */
17
+ export function initVersionControl(customConfig = {}) {
18
+ config = { ...DEFAULT_CONFIG, ...customConfig };
19
+ }
20
+ /**
21
+ * Get the storage directory for a workflow
22
+ */
23
+ function getWorkflowDir(workflowId) {
24
+ return path.join(config.storageDir, workflowId);
25
+ }
26
+ /**
27
+ * Generate a simple hash for workflow content
28
+ */
29
+ function hashWorkflow(workflow) {
30
+ const content = JSON.stringify({
31
+ nodes: workflow.nodes,
32
+ connections: workflow.connections,
33
+ settings: workflow.settings,
34
+ });
35
+ // Simple hash - good enough for comparison
36
+ let hash = 0;
37
+ for (let i = 0; i < content.length; i++) {
38
+ const char = content.charCodeAt(i);
39
+ hash = ((hash << 5) - hash) + char;
40
+ hash = hash & hash; // Convert to 32bit integer
41
+ }
42
+ return Math.abs(hash).toString(16).padStart(8, '0');
43
+ }
44
+ /**
45
+ * Save a workflow version
46
+ */
47
+ export async function saveVersion(workflow, reason = 'manual') {
48
+ if (!config.enabled)
49
+ return null;
50
+ const workflowDir = getWorkflowDir(workflow.id);
51
+ await fs.mkdir(workflowDir, { recursive: true });
52
+ const timestamp = new Date().toISOString();
53
+ const hash = hashWorkflow(workflow);
54
+ // Check if this exact version already exists (avoid duplicates)
55
+ const existing = await listVersions(workflow.id);
56
+ if (existing.length > 0 && existing[0].hash === hash) {
57
+ return null; // No changes, skip
58
+ }
59
+ const versionId = `${timestamp.replace(/[:.]/g, '-')}_${hash.slice(0, 6)}`;
60
+ const versionFile = path.join(workflowDir, `${versionId}.json`);
61
+ const versionMeta = {
62
+ id: versionId,
63
+ workflowId: workflow.id,
64
+ workflowName: workflow.name,
65
+ timestamp,
66
+ reason,
67
+ nodeCount: workflow.nodes.length,
68
+ hash,
69
+ };
70
+ const versionData = {
71
+ meta: versionMeta,
72
+ workflow,
73
+ };
74
+ await fs.writeFile(versionFile, JSON.stringify(versionData, null, 2));
75
+ // Prune old versions
76
+ await pruneVersions(workflow.id);
77
+ return versionMeta;
78
+ }
79
+ /**
80
+ * List all versions for a workflow
81
+ */
82
+ export async function listVersions(workflowId) {
83
+ const workflowDir = getWorkflowDir(workflowId);
84
+ try {
85
+ const files = await fs.readdir(workflowDir);
86
+ const versions = [];
87
+ for (const file of files) {
88
+ if (!file.endsWith('.json'))
89
+ continue;
90
+ const filePath = path.join(workflowDir, file);
91
+ const content = await fs.readFile(filePath, 'utf-8');
92
+ const data = JSON.parse(content);
93
+ if (data.meta) {
94
+ versions.push(data.meta);
95
+ }
96
+ }
97
+ // Sort by timestamp descending (newest first)
98
+ return versions.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
99
+ }
100
+ catch (error) {
101
+ if (error.code === 'ENOENT') {
102
+ return [];
103
+ }
104
+ throw error;
105
+ }
106
+ }
107
+ /**
108
+ * Get a specific version's full workflow data
109
+ */
110
+ export async function getVersion(workflowId, versionId) {
111
+ const workflowDir = getWorkflowDir(workflowId);
112
+ const versionFile = path.join(workflowDir, `${versionId}.json`);
113
+ try {
114
+ const content = await fs.readFile(versionFile, 'utf-8');
115
+ return JSON.parse(content);
116
+ }
117
+ catch (error) {
118
+ if (error.code === 'ENOENT') {
119
+ return null;
120
+ }
121
+ throw error;
122
+ }
123
+ }
124
+ /**
125
+ * Get the most recent version
126
+ */
127
+ export async function getLatestVersion(workflowId) {
128
+ const versions = await listVersions(workflowId);
129
+ if (versions.length === 0)
130
+ return null;
131
+ return getVersion(workflowId, versions[0].id);
132
+ }
133
+ /**
134
+ * Prune old versions beyond maxVersions
135
+ */
136
+ async function pruneVersions(workflowId) {
137
+ const versions = await listVersions(workflowId);
138
+ if (versions.length <= config.maxVersions) {
139
+ return 0;
140
+ }
141
+ const toDelete = versions.slice(config.maxVersions);
142
+ const workflowDir = getWorkflowDir(workflowId);
143
+ for (const version of toDelete) {
144
+ const versionFile = path.join(workflowDir, `${version.id}.json`);
145
+ await fs.unlink(versionFile);
146
+ }
147
+ return toDelete.length;
148
+ }
149
+ export function diffWorkflows(oldWorkflow, newWorkflow) {
150
+ const oldNodes = new Map(oldWorkflow.nodes.map((n) => [n.name, n]));
151
+ const newNodes = new Map(newWorkflow.nodes.map((n) => [n.name, n]));
152
+ const nodesAdded = [];
153
+ const nodesRemoved = [];
154
+ const nodesModified = [];
155
+ // Find added and modified nodes
156
+ for (const [name, node] of newNodes) {
157
+ if (!oldNodes.has(name)) {
158
+ nodesAdded.push(name);
159
+ }
160
+ else {
161
+ const oldNode = oldNodes.get(name);
162
+ if (JSON.stringify(oldNode.parameters) !== JSON.stringify(node.parameters)) {
163
+ nodesModified.push(name);
164
+ }
165
+ }
166
+ }
167
+ // Find removed nodes
168
+ for (const name of oldNodes.keys()) {
169
+ if (!newNodes.has(name)) {
170
+ nodesRemoved.push(name);
171
+ }
172
+ }
173
+ const connectionsChanged = JSON.stringify(oldWorkflow.connections) !== JSON.stringify(newWorkflow.connections);
174
+ const settingsChanged = JSON.stringify(oldWorkflow.settings) !== JSON.stringify(newWorkflow.settings);
175
+ // Generate summary
176
+ const parts = [];
177
+ if (nodesAdded.length)
178
+ parts.push(`+${nodesAdded.length} nodes`);
179
+ if (nodesRemoved.length)
180
+ parts.push(`-${nodesRemoved.length} nodes`);
181
+ if (nodesModified.length)
182
+ parts.push(`~${nodesModified.length} modified`);
183
+ if (connectionsChanged)
184
+ parts.push('connections changed');
185
+ if (settingsChanged)
186
+ parts.push('settings changed');
187
+ return {
188
+ nodesAdded,
189
+ nodesRemoved,
190
+ nodesModified,
191
+ connectionsChanged,
192
+ settingsChanged,
193
+ summary: parts.length ? parts.join(', ') : 'no changes',
194
+ };
195
+ }
196
+ /**
197
+ * Delete all versions for a workflow
198
+ */
199
+ export async function deleteAllVersions(workflowId) {
200
+ const versions = await listVersions(workflowId);
201
+ const workflowDir = getWorkflowDir(workflowId);
202
+ for (const version of versions) {
203
+ const versionFile = path.join(workflowDir, `${version.id}.json`);
204
+ await fs.unlink(versionFile);
205
+ }
206
+ // Remove the directory if empty
207
+ try {
208
+ await fs.rmdir(workflowDir);
209
+ }
210
+ catch {
211
+ // Ignore if not empty
212
+ }
213
+ return versions.length;
214
+ }
215
+ /**
216
+ * Get version control status/stats
217
+ */
218
+ export async function getVersionStats() {
219
+ let workflowCount = 0;
220
+ let totalVersions = 0;
221
+ try {
222
+ const workflows = await fs.readdir(config.storageDir);
223
+ workflowCount = workflows.length;
224
+ for (const workflowId of workflows) {
225
+ const versions = await listVersions(workflowId);
226
+ totalVersions += versions.length;
227
+ }
228
+ }
229
+ catch {
230
+ // Storage dir doesn't exist yet
231
+ }
232
+ return {
233
+ enabled: config.enabled,
234
+ storageDir: config.storageDir,
235
+ maxVersions: config.maxVersions,
236
+ workflowCount,
237
+ totalVersions,
238
+ };
239
+ }
@@ -0,0 +1,160 @@
1
+ # n8n Best Practices
2
+
3
+ ## Quick Reference
4
+
5
+ ```javascript
6
+ // Explicit reference (always use this)
7
+ {{ $('node_name').item.json.field }}
8
+
9
+ // Environment variable
10
+ {{ $env.API_KEY }}
11
+
12
+ // Config node reference
13
+ {{ $('config').item.json.setting }}
14
+
15
+ // Fallback
16
+ {{ $('source').item.json.text || 'default' }}
17
+
18
+ // Date
19
+ {{ $now.format('yyyy-MM-dd') }}
20
+ ```
21
+
22
+ ## The Rules
23
+
24
+ ### 1. snake_case
25
+
26
+ ```
27
+ Good: fetch_articles, check_approved, generate_content
28
+ Bad: FetchArticles, Check Approved, generate-content
29
+ ```
30
+
31
+ Why: Consistency, readability, auto-fixable.
32
+
33
+ ### 2. Explicit References
34
+
35
+ ```javascript
36
+ // Bad - breaks when flow changes
37
+ {{ $json.field }}
38
+
39
+ // Good - traceable, stable
40
+ {{ $('node_name').item.json.field }}
41
+ ```
42
+
43
+ Why: `$json` references "previous node" implicitly. Reorder nodes, it breaks.
44
+
45
+ ### 3. Config Node
46
+
47
+ Single source for workflow settings:
48
+
49
+ ```
50
+ [trigger] → [config] → [rest of workflow]
51
+ ```
52
+
53
+ Config node (JSON mode):
54
+ ```javascript
55
+ ={
56
+ "channel_id": "{{ $json.body.channelId || '123456' }}",
57
+ "max_items": 10,
58
+ "ai_model": "gpt-4.1-mini"
59
+ }
60
+ ```
61
+
62
+ Reference everywhere: `{{ $('config').item.json.channel_id }}`
63
+
64
+ Why: Change once, not in 5 nodes.
65
+
66
+ ### 4. Secrets in Environment
67
+
68
+ ```javascript
69
+ // Bad
70
+ { "apiKey": "sk_live_abc123" }
71
+
72
+ // Good
73
+ {{ $env.API_KEY }}
74
+ ```
75
+
76
+ ## Parameter Preservation
77
+
78
+ **Critical:** Partial updates REPLACE the entire `parameters` object.
79
+
80
+ ```javascript
81
+ // Bad - loses operation and labelIds
82
+ {
83
+ "type": "updateNode",
84
+ "nodeName": "archive_email",
85
+ "properties": {
86
+ "parameters": {
87
+ "messageId": "={{ $json.message_id }}"
88
+ }
89
+ }
90
+ }
91
+
92
+ // Good - include ALL parameters
93
+ {
94
+ "type": "updateNode",
95
+ "nodeName": "archive_email",
96
+ "properties": {
97
+ "parameters": {
98
+ "operation": "addLabels",
99
+ "messageId": "={{ $json.message_id }}",
100
+ "labelIds": ["Label_123"]
101
+ }
102
+ }
103
+ }
104
+ ```
105
+
106
+ Before updating: read current state with `workflow_get`.
107
+
108
+ ## AI Nodes
109
+
110
+ ### Structured Output
111
+
112
+ Always set for predictable JSON:
113
+
114
+ | Setting | Value |
115
+ |---------|-------|
116
+ | `promptType` | `"define"` |
117
+ | `hasOutputParser` | `true` |
118
+ | `schemaType` | `"manual"` (for nullable fields) |
119
+
120
+ ### Memory
121
+
122
+ | Don't Use | Use Instead |
123
+ |-----------|-------------|
124
+ | Windowed Buffer Memory | Postgres Chat Memory |
125
+ | In-Memory Vector Store | Postgres pgvector |
126
+
127
+ In-memory dies on restart, doesn't scale.
128
+
129
+ ## Code Nodes: Last Resort
130
+
131
+ | Need | Use Instead |
132
+ |------|-------------|
133
+ | Transform fields | Set node with expressions |
134
+ | Filter items | Filter node or Switch |
135
+ | Merge data | Merge node |
136
+ | Loop | n8n processes arrays natively |
137
+ | Date formatting | `{{ $now.format('yyyy-MM-dd') }}` |
138
+
139
+ When code IS necessary:
140
+ - Re-establishing `pairedItem` after chain breaks
141
+ - Complex conditional logic
142
+ - API parsing expressions can't handle
143
+
144
+ ## Pre-Edit Checklist
145
+
146
+ | Step | Why |
147
+ |------|-----|
148
+ | 1. Get explicit user approval | Don't surprise |
149
+ | 2. List versions | Know rollback point |
150
+ | 3. Read full workflow | Understand current state |
151
+ | 4. Make targeted change | Minimal surface area |
152
+ | 5. Validate after | Catch issues immediately |
153
+
154
+ ## Node-Specific Settings
155
+
156
+ See [Node Config](node-config.md) for:
157
+ - Resource locator (`__rl`) format with `cachedResultName`
158
+ - Google Sheets, Gmail, Discord required fields
159
+ - Set node JSON mode vs manual mapping
160
+ - Error handling options