@pagelines/n8n-mcp 0.1.0 → 0.2.0

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,241 @@
1
+ # n8n Best Practices
2
+
3
+ > Enforced by `@pagelines/n8n-mcp`
4
+
5
+ ## Guiding Principle
6
+
7
+ **What is most stable and easiest to maintain?**
8
+
9
+ | Rule | Why |
10
+ |------|-----|
11
+ | Minimize nodes | Fewer failure points, easier debugging |
12
+ | YAGNI | Build only what's needed now |
13
+ | Explicit references | `$('node_name')` not `$json` - traceable, stable |
14
+ | snake_case | `node_name` not `NodeName` - consistent, readable |
15
+
16
+ ---
17
+
18
+ ## Naming Convention
19
+
20
+ **snake_case everywhere**
21
+
22
+ ```
23
+ Workflows: content_factory, publish_linkedin, upload_image
24
+ Nodes: trigger_webhook, fetch_articles, check_approved
25
+ ```
26
+
27
+ Never `NodeName`. Always `node_name`.
28
+
29
+ ---
30
+
31
+ ## Expression References
32
+
33
+ **NEVER use `$json`. Always explicit node references.**
34
+
35
+ ```javascript
36
+ // Bad - breaks when flow changes
37
+ {{ $json.field }}
38
+
39
+ // Good - traceable and debuggable
40
+ {{ $('node_name').item.json.field }}
41
+
42
+ // Parallel branch (lookup nodes)
43
+ {{ $('lookup_node').all().length > 0 }}
44
+
45
+ // Environment variable
46
+ {{ $env.API_KEY }}
47
+
48
+ // Fallback pattern
49
+ {{ $('source').item.json.text || $('source').item.json.media_url }}
50
+ ```
51
+
52
+ ---
53
+
54
+ ## Secrets and Configuration
55
+
56
+ **Secrets in environment variables. Always.**
57
+
58
+ ```javascript
59
+ // Bad
60
+ { "apiKey": "sk_live_abc123" }
61
+
62
+ // Good
63
+ {{ $env.API_KEY }}
64
+ ```
65
+
66
+ **For workflow-specific settings, use a config node:**
67
+
68
+ ```
69
+ [trigger] → [config] → [rest of workflow]
70
+ ```
71
+
72
+ Config node uses JSON output mode:
73
+ ```javascript
74
+ ={
75
+ "channel_id": "{{ $json.body.channelId || '1234567890' }}",
76
+ "max_items": 10,
77
+ "ai_model": "gpt-4.1-mini"
78
+ }
79
+ ```
80
+
81
+ Then reference: `{{ $('config').item.json.channel_id }}`
82
+
83
+ ---
84
+
85
+ ## Workflow Editing Safety
86
+
87
+ ### The Golden Rule
88
+
89
+ **Never edit a workflow without explicit confirmation and backup.**
90
+
91
+ ### Pre-Edit Checklist
92
+
93
+ 1. **Confirm** - Get explicit user approval
94
+ 2. **List versions** - Know your rollback point
95
+ 3. **Read full state** - Understand current config
96
+ 4. **Make targeted change** - Use patch operations only
97
+ 5. **Verify** - Confirm expected state
98
+
99
+ ### Parameter Preservation
100
+
101
+ **CRITICAL:** Partial updates REPLACE the entire `parameters` object.
102
+
103
+ ```javascript
104
+ // Bad: Only updates messageId, loses operation and labelIds
105
+ {
106
+ "type": "updateNode",
107
+ "nodeName": "archive_email",
108
+ "properties": {
109
+ "parameters": {
110
+ "messageId": "={{ $json.message_id }}"
111
+ }
112
+ }
113
+ }
114
+
115
+ // Good: Include ALL required parameters
116
+ {
117
+ "type": "updateNode",
118
+ "nodeName": "archive_email",
119
+ "properties": {
120
+ "parameters": {
121
+ "operation": "addLabels",
122
+ "messageId": "={{ $json.message_id }}",
123
+ "labelIds": ["Label_123", "Label_456"]
124
+ }
125
+ }
126
+ }
127
+ ```
128
+
129
+ ---
130
+
131
+ ## Code Nodes
132
+
133
+ **Code nodes are a last resort.** Exhaust built-in options first:
134
+
135
+ | Need | Use Instead |
136
+ |------|-------------|
137
+ | Transform fields | Set node with expressions |
138
+ | Filter items | Filter node or If/Switch |
139
+ | Merge data | Merge node |
140
+ | Loop processing | n8n processes arrays natively |
141
+ | Date formatting | `{{ $now.format('yyyy-MM-dd') }}` |
142
+
143
+ **When code IS necessary:**
144
+ - Re-establishing `pairedItem` after chain breaks
145
+ - Complex conditional logic
146
+ - API response parsing expressions can't handle
147
+
148
+ **Code node rules:**
149
+ - Single responsibility (one clear purpose)
150
+ - Name it for what it does: `merge_context`, `parse_response`
151
+ - No side effects - pure data transformation
152
+
153
+ ---
154
+
155
+ ## AI Agent Best Practices
156
+
157
+ ### Structured Output
158
+
159
+ **Always enable "Require Specific Output Format"** for reliable JSON:
160
+
161
+ ```javascript
162
+ {
163
+ "promptType": "define",
164
+ "hasOutputParser": true,
165
+ "schemaType": "manual" // Required for nullable fields
166
+ }
167
+ ```
168
+
169
+ Without these, AI outputs are unpredictable.
170
+
171
+ ### Memory Storage
172
+
173
+ **Never use in-memory storage in production:**
174
+
175
+ | Don't Use | Use Instead |
176
+ |-----------|-------------|
177
+ | Windowed Buffer Memory | Postgres Chat Memory |
178
+ | In-Memory Vector Store | Postgres pgvector |
179
+
180
+ In-memory dies with restart and doesn't scale.
181
+
182
+ ---
183
+
184
+ ## Architecture Patterns
185
+
186
+ ### Single Responsibility
187
+
188
+ Don't build monolith workflows:
189
+
190
+ ```
191
+ Bad: One workflow doing signup → email → CRM → calendar → reports
192
+
193
+ Good: Five focused workflows that communicate via webhooks
194
+ ```
195
+
196
+ ### Switch > If Node
197
+
198
+ Always use Switch instead of If:
199
+ - Named outputs (not just true/false)
200
+ - Unlimited conditional branches
201
+ - Send to all matching option
202
+
203
+ ---
204
+
205
+ ## Validation Rules Enforced
206
+
207
+ | Rule | Severity | Description |
208
+ |------|----------|-------------|
209
+ | `snake_case` | warning | Names should be snake_case |
210
+ | `explicit_reference` | warning | Use `$('node')` not `$json` |
211
+ | `no_hardcoded_ids` | info | Avoid hardcoded IDs |
212
+ | `no_hardcoded_secrets` | error | Never hardcode secrets |
213
+ | `orphan_node` | warning | Node has no connections |
214
+ | `parameter_preservation` | error | Update would remove parameters |
215
+ | `code_node_usage` | info | Code node detected |
216
+ | `ai_structured_output` | warning | AI node missing structured output |
217
+ | `in_memory_storage` | warning | Using non-persistent storage |
218
+
219
+ ---
220
+
221
+ ## Quick Reference
222
+
223
+ ```javascript
224
+ // Explicit reference
225
+ {{ $('node_name').item.json.field }}
226
+
227
+ // Environment variable
228
+ {{ $env.VAR_NAME }}
229
+
230
+ // Config node reference
231
+ {{ $('config').item.json.setting }}
232
+
233
+ // Parallel branch query
234
+ {{ $('lookup').all().some(i => i.json.id === $json.id) }}
235
+
236
+ // Date formatting
237
+ {{ $now.format('yyyy-MM-dd') }}
238
+
239
+ // Fallback
240
+ {{ $json.text || $json.description || 'default' }}
241
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pagelines/n8n-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Opinionated MCP server for n8n workflow automation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.pagelines/n8n-mcp",
4
4
  "displayName": "pl-n8n-mcp",
5
5
  "description": "Opinionated MCP server for n8n workflow automation with built-in validation and safety features",
6
- "version": "0.1.0",
6
+ "version": "0.2.0",
7
7
  "author": {
8
8
  "name": "PageLines",
9
9
  "url": "https://github.com/pagelines"
@@ -46,8 +46,16 @@
46
46
  "workflow_deactivate",
47
47
  "workflow_execute",
48
48
  "workflow_validate",
49
+ "workflow_autofix",
50
+ "workflow_format",
49
51
  "execution_list",
50
- "execution_get"
52
+ "execution_get",
53
+ "version_list",
54
+ "version_get",
55
+ "version_save",
56
+ "version_rollback",
57
+ "version_diff",
58
+ "version_stats"
51
59
  ],
52
60
  "keywords": [
53
61
  "n8n",