Compare commits

..

2 Commits

Author SHA1 Message Date
Romuald Członkowski
12d7d5bdb6 fix: resolve SSE reconnection loop with separate /sse + /messages endpoints (v2.46.1) (#699)
Fix SSE clients entering rapid reconnection loops because POST /mcp
never routed messages to SSEServerTransport.handlePostMessage() (#617).

Root cause: SSE sessions were stored in a separate `this.session` property
invisible to the StreamableHTTP POST handler. The POST handler only
checked `this.transports` (StreamableHTTP map), so SSE messages were
never delivered, causing immediate reconnection and rate limiter exhaustion.

Changes:
- Add GET /sse + POST /messages endpoints following the official MCP SDK
  backward-compatible server pattern (separate endpoints per transport)
- Store SSE transports in the shared this.transports map with instanceof
  guards for type discrimination
- Remove legacy this.session singleton, resetSessionSSE(), and isExpired()
- Extract duplicated auth logic into authenticateRequest() method
- Add Bearer token auth and rate limiting to SSE endpoints
- Add skipSuccessfulRequests to authLimiter to prevent 429 storms
- Mark SSE transport as deprecated (removed in MCP SDK v2.x)

The handleRequest() codepath used by the downstream SaaS backend
(N8NMCPEngine.processRequest()) is unchanged. Session persistence
(exportSessionState/restoreSessionState) is unchanged.

Closes #617

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:00:53 +02:00
Romuald Członkowski
2d4115530c feat: add patchNodeField operation for surgical string edits (v2.46.0) (#698)
Add dedicated `patchNodeField` operation to `n8n_update_partial_workflow`
for surgical find/replace edits in node string fields. Strict alternative
to the existing `__patch_find_replace` in updateNode — errors on not-found,
detects ambiguous matches, supports replaceAll and regex flags.

Security hardening:
- Prototype pollution protection in setNestedProperty/getNestedProperty
- ReDoS protection rejecting unsafe regex patterns (nested quantifiers)
- Resource limits: max 50 patches, 500-char regex, 512KB field size

Fixes #696

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:53:44 +02:00
36 changed files with 1511 additions and 473 deletions

View File

@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [2.46.1] - 2026-04-03
### Fixed
- **Fix SSE reconnection loop** — SSE clients entering rapid reconnection loops because `POST /mcp` never routed messages to `SSEServerTransport.handlePostMessage()` (Fixes #617). Root cause: SSE sessions were stored in a separate `this.session` property invisible to the StreamableHTTP POST handler
- **Add authentication to SSE endpoints** — `GET /sse` and `POST /messages` now require Bearer token authentication, closing an auth gap where SSE connections were unauthenticated
- **Fix rate limiter exhaustion during reconnection** — added `skipSuccessfulRequests: true` to `authLimiter` so legitimate requests don't count toward the rate limit, preventing 429 storms during SSE reconnection loops
### Changed
- **Separate SSE endpoints (SDK pattern)** — SSE transport now uses dedicated `GET /sse` + `POST /messages` endpoints instead of sharing `/mcp` with StreamableHTTP, following the official MCP SDK backward-compatible server pattern
- **Unified auth into `authenticateRequest()` method** — consolidated duplicated Bearer token validation logic from three endpoints into a single method with consistent JSON-RPC error responses
- **SSE sessions use shared transports map** — removed the legacy `this.session` singleton; SSE sessions are now stored in the same `this.transports` map as StreamableHTTP sessions with `instanceof` guards for type discrimination
### Deprecated
- **SSE transport (`GET /sse`, `POST /messages`)** — SSE is deprecated in MCP SDK v1.x and removed in v2.x. Clients should migrate to StreamableHTTP (`POST /mcp`). These endpoints will be removed in a future major release
### Security
- **Rate limiting on all authenticated endpoints** — `authLimiter` now applied to `GET /sse` and `POST /messages` in addition to `POST /mcp`
- **Transport type guards** — `instanceof` checks prevent cross-protocol access (SSE session IDs rejected on StreamableHTTP endpoint and vice versa)
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
## [2.46.0] - 2026-04-03
### Added
- **`patchNodeField` operation for `n8n_update_partial_workflow`** — a dedicated, strict find/replace operation for surgical string edits in node fields (Fixes #696). Key features:
- **Strict error handling**: errors if find string not found (unlike `__patch_find_replace` which only warns)
- **Ambiguity detection**: errors if find matches multiple times unless `replaceAll: true` is set
- **`replaceAll` flag**: replace all occurrences of a string in a single patch
- **`regex` flag**: use regex patterns for advanced find/replace
- Top-level operation type for better discoverability
### Security
- **Prototype pollution protection** — `setNestedProperty` and `getNestedProperty` now reject paths containing `__proto__`, `constructor`, or `prototype`. Protects both `patchNodeField` and `updateNode` operations
- **ReDoS protection** — regex patterns with nested quantifiers or overlapping alternations are rejected to prevent catastrophic backtracking
- **Resource limits** — max 50 patches per operation, max 500-char regex patterns, max 512KB field size for regex operations
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
## [2.45.1] - 2026-04-02 ## [2.45.1] - 2026-04-02
### Fixed ### Fixed

View File

@@ -12,7 +12,6 @@ export declare class SingleSessionHTTPServer {
private sessionMetadata; private sessionMetadata;
private sessionContexts; private sessionContexts;
private contextSwitchLocks; private contextSwitchLocks;
private session;
private consoleManager; private consoleManager;
private expressServer; private expressServer;
private sessionTimeout; private sessionTimeout;
@@ -29,14 +28,14 @@ export declare class SingleSessionHTTPServer {
private isJsonRpcNotification; private isJsonRpcNotification;
private sanitizeErrorForClient; private sanitizeErrorForClient;
private updateSessionAccess; private updateSessionAccess;
private authenticateRequest;
private switchSessionContext; private switchSessionContext;
private performContextSwitch; private performContextSwitch;
private getSessionMetrics; private getSessionMetrics;
private loadAuthToken; private loadAuthToken;
private validateEnvironment; private validateEnvironment;
handleRequest(req: express.Request, res: express.Response, instanceContext?: InstanceContext): Promise<void>; handleRequest(req: express.Request, res: express.Response, instanceContext?: InstanceContext): Promise<void>;
private resetSessionSSE; private createSSESession;
private isExpired;
private isSessionExpired; private isSessionExpired;
start(): Promise<void>; start(): Promise<void>;
shutdown(): Promise<void>; shutdown(): Promise<void>;

View File

@@ -1 +1 @@
{"version":3,"file":"http-server-single-session.d.ts","sourceRoot":"","sources":["../src/http-server-single-session.ts"],"names":[],"mappings":";AAMA,OAAO,OAAO,MAAM,SAAS,CAAC;AAoB9B,OAAO,EAAE,eAAe,EAA2B,MAAM,0BAA0B,CAAC;AACpF,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AACrD,OAAO,EAAE,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AAwEpE,MAAM,WAAW,8BAA8B;IAC7C,uBAAuB,CAAC,EAAE,uBAAuB,CAAC;CACnD;AAED,qBAAa,uBAAuB;IAElC,OAAO,CAAC,UAAU,CAA8D;IAChF,OAAO,CAAC,OAAO,CAA0D;IACzE,OAAO,CAAC,eAAe,CAAsE;IAC7F,OAAO,CAAC,eAAe,CAA4D;IACnF,OAAO,CAAC,kBAAkB,CAAyC;IACnE,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,cAAc,CAAwB;IAC9C,OAAO,CAAC,aAAa,CAAM;IAG3B,OAAO,CAAC,cAAc,CAER;IACd,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,YAAY,CAA+B;IACnD,OAAO,CAAC,uBAAuB,CAAC,CAA0B;gBAE9C,OAAO,CAAC,EAAE,8BAA8B;IAapD,OAAO,CAAC,mBAAmB;IAmB3B,OAAO,CAAC,sBAAsB;YAqChB,aAAa;IAuC3B,OAAO,CAAC,qBAAqB;IAO7B,OAAO,CAAC,gBAAgB;IAkBxB,OAAO,CAAC,gBAAgB;IAYxB,OAAO,CAAC,qBAAqB;IAa7B,OAAO,CAAC,sBAAsB;IAkC9B,OAAO,CAAC,mBAAmB;YASb,oBAAoB;YAwBpB,oBAAoB;IAwBlC,OAAO,CAAC,iBAAiB;IAsBzB,OAAO,CAAC,aAAa;IA2BrB,OAAO,CAAC,mBAAmB;IAoDrB,aAAa,CACjB,GAAG,EAAE,OAAO,CAAC,OAAO,EACpB,GAAG,EAAE,OAAO,CAAC,QAAQ,EACrB,eAAe,CAAC,EAAE,eAAe,GAChC,OAAO,CAAC,IAAI,CAAC;YAsRF,eAAe;IA8D7B,OAAO,CAAC,SAAS;IAYjB,OAAO,CAAC,gBAAgB;IASlB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAgnBtB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IA2D/B,cAAc,IAAI;QAChB,MAAM,EAAE,OAAO,CAAC;QAChB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,QAAQ,CAAC,EAAE;YACT,KAAK,EAAE,MAAM,CAAC;YACd,MAAM,EAAE,MAAM,CAAC;YACf,OAAO,EAAE,MAAM,CAAC;YAChB,GAAG,EAAE,MAAM,CAAC;YACZ,UAAU,EAAE,MAAM,EAAE,CAAC;SACtB,CAAC;KACH;IAmDM,kBAAkB,IAAI,YAAY,EAAE;IAoEpC,mBAAmB,CAAC,QAAQ,EAAE,YAAY,EAAE,GAAG,MAAM;CAsG7D"} {"version":3,"file":"http-server-single-session.d.ts","sourceRoot":"","sources":["../src/http-server-single-session.ts"],"names":[],"mappings":";AAMA,OAAO,OAAO,MAAM,SAAS,CAAC;AAoB9B,OAAO,EAAE,eAAe,EAA2B,MAAM,0BAA0B,CAAC;AACpF,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AACrD,OAAO,EAAE,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AA+DpE,MAAM,WAAW,8BAA8B;IAC7C,uBAAuB,CAAC,EAAE,uBAAuB,CAAC;CACnD;AAED,qBAAa,uBAAuB;IAGlC,OAAO,CAAC,UAAU,CAAmF;IACrG,OAAO,CAAC,OAAO,CAA0D;IACzE,OAAO,CAAC,eAAe,CAAsE;IAC7F,OAAO,CAAC,eAAe,CAA4D;IACnF,OAAO,CAAC,kBAAkB,CAAyC;IACnE,OAAO,CAAC,cAAc,CAAwB;IAC9C,OAAO,CAAC,aAAa,CAAM;IAG3B,OAAO,CAAC,cAAc,CAER;IACd,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,YAAY,CAA+B;IACnD,OAAO,CAAC,uBAAuB,CAAC,CAA0B;gBAE9C,OAAO,CAAC,EAAE,8BAA8B;IAapD,OAAO,CAAC,mBAAmB;IAmB3B,OAAO,CAAC,sBAAsB;YAqChB,aAAa;IAuC3B,OAAO,CAAC,qBAAqB;IAO7B,OAAO,CAAC,gBAAgB;IAkBxB,OAAO,CAAC,gBAAgB;IAYxB,OAAO,CAAC,qBAAqB;IAa7B,OAAO,CAAC,sBAAsB;IAkC9B,OAAO,CAAC,mBAAmB;IAW3B,OAAO,CAAC,mBAAmB;YAyCb,oBAAoB;YAwBpB,oBAAoB;IAwBlC,OAAO,CAAC,iBAAiB;IAsBzB,OAAO,CAAC,aAAa;IA2BrB,OAAO,CAAC,mBAAmB;IAoDrB,aAAa,CACjB,GAAG,EAAE,OAAO,CAAC,OAAO,EACpB,GAAG,EAAE,OAAO,CAAC,QAAQ,EACrB,eAAe,CAAC,EAAE,eAAe,GAChC,OAAO,CAAC,IAAI,CAAC;YAsSF,gBAAgB;IA+C9B,OAAO,CAAC,gBAAgB;IASlB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAynBtB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAgD/B,cAAc,IAAI;QAChB,MAAM,EAAE,OAAO,CAAC;QAChB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,QAAQ,CAAC,EAAE;YACT,KAAK,EAAE,MAAM,CAAC;YACd,MAAM,EAAE,MAAM,CAAC;YACf,OAAO,EAAE,MAAM,CAAC;YAChB,GAAG,EAAE,MAAM,CAAC;YACZ,UAAU,EAAE,MAAM,EAAE,CAAC;SACtB,CAAC;KACH;IAmCM,kBAAkB,IAAI,YAAY,EAAE;IAoEpC,mBAAmB,CAAC,QAAQ,EAAE,YAAY,EAAE,GAAG,MAAM;CAsG7D"}

View File

@@ -51,7 +51,6 @@ class SingleSessionHTTPServer {
this.sessionMetadata = {}; this.sessionMetadata = {};
this.sessionContexts = {}; this.sessionContexts = {};
this.contextSwitchLocks = new Map(); this.contextSwitchLocks = new Map();
this.session = null;
this.consoleManager = new console_manager_1.ConsoleManager(); this.consoleManager = new console_manager_1.ConsoleManager();
this.sessionTimeout = parseInt(process.env.SESSION_TIMEOUT_MINUTES || '30', 10) * 60 * 1000; this.sessionTimeout = parseInt(process.env.SESSION_TIMEOUT_MINUTES || '30', 10) * 60 * 1000;
this.authToken = null; this.authToken = null;
@@ -170,6 +169,39 @@ class SingleSessionHTTPServer {
this.sessionMetadata[sessionId].lastAccess = new Date(); this.sessionMetadata[sessionId].lastAccess = new Date();
} }
} }
authenticateRequest(req, res) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
const reason = !authHeader ? 'no_auth_header' : 'invalid_auth_format';
logger_1.logger.warn('Authentication failed', {
ip: req.ip,
userAgent: req.get('user-agent'),
reason
});
res.status(401).json({
jsonrpc: '2.0',
error: { code: -32001, message: 'Unauthorized' },
id: null
});
return false;
}
const token = authHeader.slice(7).trim();
const isValid = this.authToken && auth_1.AuthManager.timingSafeCompare(token, this.authToken);
if (!isValid) {
logger_1.logger.warn('Authentication failed: Invalid token', {
ip: req.ip,
userAgent: req.get('user-agent'),
reason: 'invalid_token'
});
res.status(401).json({
jsonrpc: '2.0',
error: { code: -32001, message: 'Unauthorized' },
id: null
});
return false;
}
return true;
}
async switchSessionContext(sessionId, newContext) { async switchSessionContext(sessionId, newContext) {
const existingLock = this.contextSwitchLocks.get(sessionId); const existingLock = this.contextSwitchLocks.get(sessionId);
if (existingLock) { if (existingLock) {
@@ -392,6 +424,18 @@ class SingleSessionHTTPServer {
return; return;
} }
logger_1.logger.info('handleRequest: Reusing existing transport for session', { sessionId }); logger_1.logger.info('handleRequest: Reusing existing transport for session', { sessionId });
if (this.transports[sessionId] instanceof sse_js_1.SSEServerTransport) {
logger_1.logger.warn('handleRequest: SSE session used on StreamableHTTP endpoint', { sessionId });
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Session uses SSE transport. Send messages to POST /messages?sessionId=<id> instead.'
},
id: req.body?.id || null
});
return;
}
transport = this.transports[sessionId]; transport = this.transports[sessionId];
if (!transport) { if (!transport) {
if (this.isJsonRpcNotification(req.body)) { if (this.isJsonRpcNotification(req.body)) {
@@ -486,54 +530,33 @@ class SingleSessionHTTPServer {
} }
}); });
} }
async resetSessionSSE(res) { async createSSESession(res) {
if (this.session) { if (!this.canCreateSession()) {
const sessionId = this.session.sessionId; logger_1.logger.warn('SSE session creation rejected: session limit reached', {
logger_1.logger.info('Closing previous session for SSE', { sessionId }); currentSessions: this.getActiveSessionCount(),
if (this.session.server && typeof this.session.server.close === 'function') { maxSessions: MAX_SESSIONS
try {
await this.session.server.close();
}
catch (serverError) {
logger_1.logger.warn('Error closing server for SSE session', { sessionId, error: serverError });
}
}
try {
await this.session.transport.close();
}
catch (transportError) {
logger_1.logger.warn('Error closing transport for SSE session', { sessionId, error: transportError });
}
}
try {
logger_1.logger.info('Creating new N8NDocumentationMCPServer for SSE...');
const server = new server_1.N8NDocumentationMCPServer(undefined, undefined, {
generateWorkflowHandler: this.generateWorkflowHandler,
}); });
const sessionId = (0, uuid_1.v4)(); throw new Error(`Session limit reached (${MAX_SESSIONS})`);
logger_1.logger.info('Creating SSEServerTransport...');
const transport = new sse_js_1.SSEServerTransport('/mcp', res);
logger_1.logger.info('Connecting server to SSE transport...');
await server.connect(transport);
this.session = {
server,
transport,
lastAccess: new Date(),
sessionId,
initialized: false,
isSSE: true
};
logger_1.logger.info('Created new SSE session successfully', { sessionId: this.session.sessionId });
} }
catch (error) { const server = new server_1.N8NDocumentationMCPServer(undefined, undefined, {
logger_1.logger.error('Failed to create SSE session:', error); generateWorkflowHandler: this.generateWorkflowHandler,
throw error; });
} const transport = new sse_js_1.SSEServerTransport('/messages', res);
} const sessionId = transport.sessionId;
isExpired() { this.transports[sessionId] = transport;
if (!this.session) this.servers[sessionId] = server;
return true; this.sessionMetadata[sessionId] = {
return Date.now() - this.session.lastAccess.getTime() > this.sessionTimeout; lastAccess: new Date(),
createdAt: new Date()
};
res.on('close', () => {
logger_1.logger.info('SSE connection closed by client', { sessionId });
this.removeSession(sessionId, 'sse_disconnect').catch(err => {
logger_1.logger.warn('Error cleaning up SSE session on disconnect', { sessionId, error: err });
});
});
await server.connect(transport);
logger_1.logger.info('SSE session created', { sessionId, transport: 'SSEServerTransport' });
} }
isSessionExpired(sessionId) { isSessionExpired(sessionId) {
const metadata = this.sessionMetadata[sessionId]; const metadata = this.sessionMetadata[sessionId];
@@ -601,7 +624,7 @@ class SingleSessionHTTPServer {
authentication: { authentication: {
type: 'Bearer Token', type: 'Bearer Token',
header: 'Authorization: Bearer <token>', header: 'Authorization: Bearer <token>',
required_for: ['POST /mcp'] required_for: ['POST /mcp', 'GET /sse', 'POST /messages']
}, },
documentation: 'https://github.com/czlonkowski/n8n-mcp' documentation: 'https://github.com/czlonkowski/n8n-mcp'
}); });
@@ -633,7 +656,7 @@ class SingleSessionHTTPServer {
}, },
activeTransports: activeTransports.length, activeTransports: activeTransports.length,
activeServers: activeServers.length, activeServers: activeServers.length,
legacySessionActive: !!this.session, legacySessionActive: false,
memory: { memory: {
used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024), total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
@@ -673,9 +696,10 @@ class SingleSessionHTTPServer {
}); });
app.get('/mcp', async (req, res) => { app.get('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id']; const sessionId = req.headers['mcp-session-id'];
if (sessionId && this.transports[sessionId]) { const existingTransport = sessionId ? this.transports[sessionId] : undefined;
if (existingTransport && existingTransport instanceof streamableHttp_js_1.StreamableHTTPServerTransport) {
try { try {
await this.transports[sessionId].handleRequest(req, res, undefined); await existingTransport.handleRequest(req, res, undefined);
return; return;
} }
catch (error) { catch (error) {
@@ -684,22 +708,12 @@ class SingleSessionHTTPServer {
} }
const accept = req.headers.accept; const accept = req.headers.accept;
if (accept && accept.includes('text/event-stream')) { if (accept && accept.includes('text/event-stream')) {
logger_1.logger.info('SSE stream request received - establishing SSE connection'); logger_1.logger.info('SSE request on /mcp redirected to /sse', { ip: req.ip });
try { res.status(400).json({
await this.resetSessionSSE(res); error: 'SSE transport uses /sse endpoint',
logger_1.logger.info('SSE connection established successfully'); message: 'Connect via GET /sse for SSE streaming. POST messages to /messages?sessionId=<id>.',
} documentation: 'https://github.com/czlonkowski/n8n-mcp'
catch (error) { });
logger_1.logger.error('Failed to establish SSE connection:', error);
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Failed to establish SSE connection'
},
id: null
});
}
return; return;
} }
if (process.env.N8N_MODE === 'true') { if (process.env.N8N_MODE === 'true') {
@@ -724,9 +738,23 @@ class SingleSessionHTTPServer {
mcp: { mcp: {
method: 'POST', method: 'POST',
path: '/mcp', path: '/mcp',
description: 'Main MCP JSON-RPC endpoint', description: 'Main MCP JSON-RPC endpoint (StreamableHTTP)',
authentication: 'Bearer token required' authentication: 'Bearer token required'
}, },
sse: {
method: 'GET',
path: '/sse',
description: 'DEPRECATED: SSE stream for legacy clients. Migrate to StreamableHTTP (POST /mcp).',
authentication: 'Bearer token required',
deprecated: true
},
messages: {
method: 'POST',
path: '/messages',
description: 'DEPRECATED: Message delivery for SSE sessions. Migrate to StreamableHTTP (POST /mcp).',
authentication: 'Bearer token required',
deprecated: true
},
health: { health: {
method: 'GET', method: 'GET',
path: '/health', path: '/health',
@@ -743,6 +771,92 @@ class SingleSessionHTTPServer {
documentation: 'https://github.com/czlonkowski/n8n-mcp' documentation: 'https://github.com/czlonkowski/n8n-mcp'
}); });
}); });
const authLimiter = (0, express_rate_limit_1.default)({
windowMs: parseInt(process.env.AUTH_RATE_LIMIT_WINDOW || '900000'),
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX || '20'),
message: {
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Too many authentication attempts. Please try again later.'
},
id: null
},
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true,
handler: (req, res) => {
logger_1.logger.warn('Rate limit exceeded', {
ip: req.ip,
userAgent: req.get('user-agent'),
event: 'rate_limit'
});
res.status(429).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Too many authentication attempts'
},
id: null
});
}
});
app.get('/sse', authLimiter, async (req, res) => {
if (!this.authenticateRequest(req, res))
return;
logger_1.logger.warn('SSE transport is deprecated and will be removed in a future release. Migrate to StreamableHTTP (POST /mcp).', {
ip: req.ip,
userAgent: req.get('user-agent')
});
try {
await this.createSSESession(res);
}
catch (error) {
logger_1.logger.error('Failed to create SSE session:', error);
if (!res.headersSent) {
res.status(error instanceof Error && error.message.includes('Session limit')
? 429 : 500).json({
error: error instanceof Error ? error.message : 'Failed to establish SSE connection'
});
}
}
});
app.post('/messages', authLimiter, jsonParser, async (req, res) => {
if (!this.authenticateRequest(req, res))
return;
const sessionId = req.query.sessionId;
if (!sessionId) {
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32602, message: 'Missing sessionId query parameter' },
id: req.body?.id || null
});
return;
}
const transport = this.transports[sessionId];
if (!transport || !(transport instanceof sse_js_1.SSEServerTransport)) {
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32000, message: 'SSE session not found or expired' },
id: req.body?.id || null
});
return;
}
this.updateSessionAccess(sessionId);
try {
await transport.handlePostMessage(req, res, req.body);
}
catch (error) {
logger_1.logger.error('SSE message handling error', { sessionId, error });
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal error processing SSE message' },
id: req.body?.id || null
});
}
}
});
app.delete('/mcp', async (req, res) => { app.delete('/mcp', async (req, res) => {
const mcpSessionId = req.headers['mcp-session-id']; const mcpSessionId = req.headers['mcp-session-id'];
if (!mcpSessionId) { if (!mcpSessionId) {
@@ -796,35 +910,6 @@ class SingleSessionHTTPServer {
}); });
} }
}); });
const authLimiter = (0, express_rate_limit_1.default)({
windowMs: parseInt(process.env.AUTH_RATE_LIMIT_WINDOW || '900000'),
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX || '20'),
message: {
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Too many authentication attempts. Please try again later.'
},
id: null
},
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => {
logger_1.logger.warn('Rate limit exceeded', {
ip: req.ip,
userAgent: req.get('user-agent'),
event: 'rate_limit'
});
res.status(429).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Too many authentication attempts'
},
id: null
});
}
});
app.post('/mcp', authLimiter, jsonParser, async (req, res) => { app.post('/mcp', authLimiter, jsonParser, async (req, res) => {
logger_1.logger.info('POST /mcp request received - DETAILED DEBUG', { logger_1.logger.info('POST /mcp request received - DETAILED DEBUG', {
headers: req.headers, headers: req.headers,
@@ -864,63 +949,10 @@ class SingleSessionHTTPServer {
req.removeListener('close', closeHandler); req.removeListener('close', closeHandler);
}); });
} }
const authHeader = req.headers.authorization; if (!this.authenticateRequest(req, res))
if (!authHeader) {
logger_1.logger.warn('Authentication failed: Missing Authorization header', {
ip: req.ip,
userAgent: req.get('user-agent'),
reason: 'no_auth_header'
});
res.status(401).json({
jsonrpc: '2.0',
error: {
code: -32001,
message: 'Unauthorized'
},
id: null
});
return; return;
}
if (!authHeader.startsWith('Bearer ')) {
logger_1.logger.warn('Authentication failed: Invalid Authorization header format (expected Bearer token)', {
ip: req.ip,
userAgent: req.get('user-agent'),
reason: 'invalid_auth_format',
headerPrefix: authHeader.substring(0, Math.min(authHeader.length, 10)) + '...'
});
res.status(401).json({
jsonrpc: '2.0',
error: {
code: -32001,
message: 'Unauthorized'
},
id: null
});
return;
}
const token = authHeader.slice(7).trim();
const isValidToken = this.authToken &&
auth_1.AuthManager.timingSafeCompare(token, this.authToken);
if (!isValidToken) {
logger_1.logger.warn('Authentication failed: Invalid token', {
ip: req.ip,
userAgent: req.get('user-agent'),
reason: 'invalid_token'
});
res.status(401).json({
jsonrpc: '2.0',
error: {
code: -32001,
message: 'Unauthorized'
},
id: null
});
return;
}
logger_1.logger.info('Authentication successful - proceeding to handleRequest', { logger_1.logger.info('Authentication successful - proceeding to handleRequest', {
hasSession: !!this.session, activeSessions: this.getActiveSessionCount()
sessionType: this.session?.isSSE ? 'SSE' : 'StreamableHTTP',
sessionInitialized: this.session?.initialized
}); });
const instanceContext = (() => { const instanceContext = (() => {
const headers = extractMultiTenantHeaders(req); const headers = extractMultiTenantHeaders(req);
@@ -1008,6 +1040,7 @@ class SingleSessionHTTPServer {
console.log(`Session Limits: ${MAX_SESSIONS} max sessions, ${this.sessionTimeout / 1000 / 60}min timeout`); console.log(`Session Limits: ${MAX_SESSIONS} max sessions, ${this.sessionTimeout / 1000 / 60}min timeout`);
console.log(`Health check: ${endpoints.health}`); console.log(`Health check: ${endpoints.health}`);
console.log(`MCP endpoint: ${endpoints.mcp}`); console.log(`MCP endpoint: ${endpoints.mcp}`);
console.log(`SSE endpoint: ${baseUrl}/sse (legacy clients)`);
if (isProduction) { if (isProduction) {
console.log('🔒 Running in PRODUCTION mode - enhanced security enabled'); console.log('🔒 Running in PRODUCTION mode - enhanced security enabled');
} }
@@ -1061,16 +1094,6 @@ class SingleSessionHTTPServer {
logger_1.logger.warn(`Error closing transport for session ${sessionId}:`, error); logger_1.logger.warn(`Error closing transport for session ${sessionId}:`, error);
} }
} }
if (this.session) {
try {
await this.session.transport.close();
logger_1.logger.info('Legacy session closed');
}
catch (error) {
logger_1.logger.warn('Error closing legacy session:', error);
}
this.session = null;
}
if (this.expressServer) { if (this.expressServer) {
await new Promise((resolve) => { await new Promise((resolve) => {
this.expressServer.close(() => { this.expressServer.close(() => {
@@ -1090,22 +1113,8 @@ class SingleSessionHTTPServer {
} }
getSessionInfo() { getSessionInfo() {
const metrics = this.getSessionMetrics(); const metrics = this.getSessionMetrics();
if (!this.session) {
return {
active: false,
sessions: {
total: metrics.totalSessions,
active: metrics.activeSessions,
expired: metrics.expiredSessions,
max: MAX_SESSIONS,
sessionIds: Object.keys(this.transports)
}
};
}
return { return {
active: true, active: metrics.activeSessions > 0,
sessionId: this.session.sessionId,
age: Date.now() - this.session.lastAccess.getTime(),
sessions: { sessions: {
total: metrics.totalSessions, total: metrics.totalSessions,
active: metrics.activeSessions, active: metrics.activeSessions,

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"version":3,"file":"handlers-n8n-manager.d.ts","sourceRoot":"","sources":["../../src/mcp/handlers-n8n-manager.ts"],"names":[],"mappings":"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"} {"version":3,"file":"handlers-n8n-manager.d.ts","sourceRoot":"","sources":["../../src/mcp/handlers-n8n-manager.ts"],"names":[],"mappings":"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"}

View File

@@ -519,6 +519,24 @@ async function handleUpdateWorkflow(args, repository, context) {
if (updateData.nodes || updateData.connections) { if (updateData.nodes || updateData.connections) {
const current = await client.getWorkflow(id); const current = await client.getWorkflow(id);
workflowBefore = JSON.parse(JSON.stringify(current)); workflowBefore = JSON.parse(JSON.stringify(current));
if (updateData.nodes && current.nodes) {
const currentById = new Map();
const currentByName = new Map();
for (const node of current.nodes) {
if (node.id)
currentById.set(node.id, node);
currentByName.set(node.name, node);
}
for (const node of updateData.nodes) {
const hasCredentials = node.credentials && typeof node.credentials === 'object' && Object.keys(node.credentials).length > 0;
if (!hasCredentials) {
const match = (node.id && currentById.get(node.id)) || currentByName.get(node.name);
if (match?.credentials) {
node.credentials = match.credentials;
}
}
}
}
if (createBackup !== false) { if (createBackup !== false) {
try { try {
const versioningService = new workflow_versioning_service_1.WorkflowVersioningService(repository, client); const versioningService = new workflow_versioning_service_1.WorkflowVersioningService(repository, client);

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"version":3,"file":"handlers-workflow-diff.d.ts","sourceRoot":"","sources":["../../src/mcp/handlers-workflow-diff.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAMnD,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAE5D,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAkF7D,wBAAsB,2BAA2B,CAC/C,IAAI,EAAE,OAAO,EACb,UAAU,EAAE,cAAc,EAC1B,OAAO,CAAC,EAAE,eAAe,GACxB,OAAO,CAAC,eAAe,CAAC,CAib1B"} {"version":3,"file":"handlers-workflow-diff.d.ts","sourceRoot":"","sources":["../../src/mcp/handlers-workflow-diff.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAMnD,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAE5D,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAoF7D,wBAAsB,2BAA2B,CAC/C,IAAI,EAAE,OAAO,EACb,UAAU,EAAE,cAAc,EAC1B,OAAO,CAAC,EAAE,eAAe,GACxB,OAAO,CAAC,eAAe,CAAC,CAib1B"}

View File

@@ -51,7 +51,7 @@ function getValidator(repository) {
return cachedValidator; return cachedValidator;
} }
const NODE_TARGETING_OPERATIONS = new Set([ const NODE_TARGETING_OPERATIONS = new Set([
'updateNode', 'removeNode', 'moveNode', 'enableNode', 'disableNode' 'updateNode', 'removeNode', 'moveNode', 'enableNode', 'disableNode', 'patchNodeField'
]); ]);
const workflowDiffSchema = zod_1.z.object({ const workflowDiffSchema = zod_1.z.object({
id: zod_1.z.string(), id: zod_1.z.string(),
@@ -62,6 +62,8 @@ const workflowDiffSchema = zod_1.z.object({
nodeId: zod_1.z.string().optional(), nodeId: zod_1.z.string().optional(),
nodeName: zod_1.z.string().optional(), nodeName: zod_1.z.string().optional(),
updates: zod_1.z.any().optional(), updates: zod_1.z.any().optional(),
fieldPath: zod_1.z.string().optional(),
patches: zod_1.z.any().optional(),
position: zod_1.z.tuple([zod_1.z.number(), zod_1.z.number()]).optional(), position: zod_1.z.tuple([zod_1.z.number(), zod_1.z.number()]).optional(),
source: zod_1.z.string().optional(), source: zod_1.z.string().optional(),
target: zod_1.z.string().optional(), target: zod_1.z.string().optional(),
@@ -506,6 +508,8 @@ function inferIntentFromOperations(operations) {
return `Remove node ${op.nodeName || op.nodeId || ''}`.trim(); return `Remove node ${op.nodeName || op.nodeId || ''}`.trim();
case 'updateNode': case 'updateNode':
return `Update node ${op.nodeName || op.nodeId || ''}`.trim(); return `Update node ${op.nodeName || op.nodeId || ''}`.trim();
case 'patchNodeField':
return `Patch field on node ${op.nodeName || op.nodeId || ''}`.trim();
case 'addConnection': case 'addConnection':
return `Connect ${op.source || 'node'} to ${op.target || 'node'}`; return `Connect ${op.source || 'node'} to ${op.target || 'node'}`;
case 'removeConnection': case 'removeConnection':
@@ -538,6 +542,10 @@ function inferIntentFromOperations(operations) {
const count = opTypes.filter((t) => t === 'updateNode').length; const count = opTypes.filter((t) => t === 'updateNode').length;
summary.push(`update ${count} node${count > 1 ? 's' : ''}`); summary.push(`update ${count} node${count > 1 ? 's' : ''}`);
} }
if (typeSet.has('patchNodeField')) {
const count = opTypes.filter((t) => t === 'patchNodeField').length;
summary.push(`patch ${count} field${count > 1 ? 's' : ''}`);
}
if (typeSet.has('addConnection') || typeSet.has('rewireConnection')) { if (typeSet.has('addConnection') || typeSet.has('rewireConnection')) {
summary.push('modify connections'); summary.push('modify connections');
} }

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"version":3,"file":"n8n-update-partial-workflow.d.ts","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/workflow_management/n8n-update-partial-workflow.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAE7C,eAAO,MAAM,2BAA2B,EAAE,iBA2azC,CAAC"} {"version":3,"file":"n8n-update-partial-workflow.d.ts","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/workflow_management/n8n-update-partial-workflow.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAE7C,eAAO,MAAM,2BAA2B,EAAE,iBA0bzC,CAAC"}

View File

@@ -5,7 +5,7 @@ exports.n8nUpdatePartialWorkflowDoc = {
name: 'n8n_update_partial_workflow', name: 'n8n_update_partial_workflow',
category: 'workflow_management', category: 'workflow_management',
essentials: { essentials: {
description: 'Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, moveNode, enable/disableNode, addConnection, removeConnection, rewireConnection, cleanStaleConnections, replaceConnections, updateSettings, updateName, add/removeTag, activateWorkflow, deactivateWorkflow, transferWorkflow. Supports smart parameters (branch, case) for multi-output nodes. Full support for AI connections (ai_languageModel, ai_tool, ai_memory, ai_embedding, ai_vectorStore, ai_document, ai_textSplitter, ai_outputParser).', description: 'Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, patchNodeField, moveNode, enable/disableNode, addConnection, removeConnection, rewireConnection, cleanStaleConnections, replaceConnections, updateSettings, updateName, add/removeTag, activateWorkflow, deactivateWorkflow, transferWorkflow. Supports smart parameters (branch, case) for multi-output nodes. Full support for AI connections (ai_languageModel, ai_tool, ai_memory, ai_embedding, ai_vectorStore, ai_document, ai_textSplitter, ai_outputParser).',
keyParameters: ['id', 'operations', 'continueOnError'], keyParameters: ['id', 'operations', 'continueOnError'],
example: 'n8n_update_partial_workflow({id: "wf_123", operations: [{type: "rewireConnection", source: "IF", from: "Old", to: "New", branch: "true"}]})', example: 'n8n_update_partial_workflow({id: "wf_123", operations: [{type: "rewireConnection", source: "IF", from: "Old", to: "New", branch: "true"}]})',
performance: 'Fast (50-200ms)', performance: 'Fast (50-200ms)',
@@ -28,14 +28,15 @@ exports.n8nUpdatePartialWorkflowDoc = {
] ]
}, },
full: { full: {
description: `Updates workflows using surgical diff operations instead of full replacement. Supports 17 operation types for precise modifications. Operations are validated and applied atomically by default - all succeed or none are applied. description: `Updates workflows using surgical diff operations instead of full replacement. Supports 18 operation types for precise modifications. Operations are validated and applied atomically by default - all succeed or none are applied.
## Available Operations: ## Available Operations:
### Node Operations (6 types): ### Node Operations (7 types):
- **addNode**: Add a new node with name, type, and position (required) - **addNode**: Add a new node with name, type, and position (required)
- **removeNode**: Remove a node by ID or name - **removeNode**: Remove a node by ID or name
- **updateNode**: Update node properties using dot notation (e.g., 'parameters.url') - **updateNode**: Update node properties using dot notation (e.g., 'parameters.url')
- **patchNodeField**: Surgically edit string fields using find/replace patches. Strict mode: errors if find string not found, errors if multiple matches (ambiguity) unless replaceAll is set. Supports replaceAll and regex flags.
- **moveNode**: Change node position [x, y] - **moveNode**: Change node position [x, y]
- **enableNode**: Enable a disabled node - **enableNode**: Enable a disabled node
- **disableNode**: Disable an active node - **disableNode**: Disable an active node
@@ -336,6 +337,11 @@ n8n_update_partial_workflow({
'// Validate before applying\nn8n_update_partial_workflow({id: "bcd", operations: [{type: "removeNode", nodeName: "Old Process"}], validateOnly: true})', '// Validate before applying\nn8n_update_partial_workflow({id: "bcd", operations: [{type: "removeNode", nodeName: "Old Process"}], validateOnly: true})',
'// Surgically edit code using __patch_find_replace (avoids replacing entire code block)\nn8n_update_partial_workflow({id: "pfr1", operations: [{type: "updateNode", nodeName: "Code", updates: {"parameters.jsCode": {"__patch_find_replace": [{"find": "const limit = 10;", "replace": "const limit = 50;"}]}}}]})', '// Surgically edit code using __patch_find_replace (avoids replacing entire code block)\nn8n_update_partial_workflow({id: "pfr1", operations: [{type: "updateNode", nodeName: "Code", updates: {"parameters.jsCode": {"__patch_find_replace": [{"find": "const limit = 10;", "replace": "const limit = 50;"}]}}}]})',
'// Multiple sequential patches on the same property\nn8n_update_partial_workflow({id: "pfr2", operations: [{type: "updateNode", nodeName: "Code", updates: {"parameters.jsCode": {"__patch_find_replace": [{"find": "api.old-domain.com", "replace": "api.new-domain.com"}, {"find": "Authorization: Bearer old_token", "replace": "Authorization: Bearer new_token"}]}}}]})', '// Multiple sequential patches on the same property\nn8n_update_partial_workflow({id: "pfr2", operations: [{type: "updateNode", nodeName: "Code", updates: {"parameters.jsCode": {"__patch_find_replace": [{"find": "api.old-domain.com", "replace": "api.new-domain.com"}, {"find": "Authorization: Bearer old_token", "replace": "Authorization: Bearer new_token"}]}}}]})',
'\n// ============ PATCHNODEFIELD EXAMPLES (strict find/replace) ============',
'// Surgical code edit with patchNodeField (errors if not found)\nn8n_update_partial_workflow({id: "pnf1", operations: [{type: "patchNodeField", nodeName: "Code", fieldPath: "parameters.jsCode", patches: [{find: "const limit = 10;", replace: "const limit = 50;"}]}]})',
'// Replace all occurrences of a string\nn8n_update_partial_workflow({id: "pnf2", operations: [{type: "patchNodeField", nodeName: "Code", fieldPath: "parameters.jsCode", patches: [{find: "api.old.com", replace: "api.new.com", replaceAll: true}]}]})',
'// Multiple sequential patches\nn8n_update_partial_workflow({id: "pnf3", operations: [{type: "patchNodeField", nodeName: "Set Email", fieldPath: "parameters.assignments.assignments.6.value", patches: [{find: "© 2025 n8n-mcp", replace: "© 2026 n8n-mcp"}, {find: "<p>Unsubscribe</p>", replace: ""}]}]})',
'// Regex-based replacement\nn8n_update_partial_workflow({id: "pnf4", operations: [{type: "patchNodeField", nodeName: "Code", fieldPath: "parameters.jsCode", patches: [{find: "const\\\\s+limit\\\\s*=\\\\s*\\\\d+", replace: "const limit = 100", regex: true}]}]})',
'\n// ============ AI CONNECTION EXAMPLES ============', '\n// ============ AI CONNECTION EXAMPLES ============',
'// Connect language model to AI Agent\nn8n_update_partial_workflow({id: "ai1", operations: [{type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel"}]})', '// Connect language model to AI Agent\nn8n_update_partial_workflow({id: "ai1", operations: [{type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel"}]})',
'// Connect tool to AI Agent\nn8n_update_partial_workflow({id: "ai2", operations: [{type: "addConnection", source: "HTTP Request Tool", target: "AI Agent", sourceOutput: "ai_tool"}]})', '// Connect tool to AI Agent\nn8n_update_partial_workflow({id: "ai2", operations: [{type: "addConnection", source: "HTTP Request Tool", target: "AI Agent", sourceOutput: "ai_tool"}]})',
@@ -374,7 +380,10 @@ n8n_update_partial_workflow({
'Configure Vector Store retrieval systems', 'Configure Vector Store retrieval systems',
'Swap language models in existing AI workflows', 'Swap language models in existing AI workflows',
'Batch-update AI tool connections', 'Batch-update AI tool connections',
'Transfer workflows between team projects (enterprise)' 'Transfer workflows between team projects (enterprise)',
'Surgical string edits in email templates, code, or JSON bodies (patchNodeField)',
'Fix typos or update URLs in large HTML content without re-transmitting the full string',
'Bulk find/replace across node field content (replaceAll flag)'
], ],
performance: 'Very fast - typically 50-200ms. Much faster than full updates as only changes are processed.', performance: 'Very fast - typically 50-200ms. Much faster than full updates as only changes are processed.',
bestPractices: [ bestPractices: [
@@ -397,7 +406,10 @@ n8n_update_partial_workflow({
'To remove properties, set them to null in the updates object', 'To remove properties, set them to null in the updates object',
'When migrating from deprecated properties, remove the old property and add the new one in the same operation', 'When migrating from deprecated properties, remove the old property and add the new one in the same operation',
'Use null to resolve mutual exclusivity validation errors between properties', 'Use null to resolve mutual exclusivity validation errors between properties',
'Batch multiple property removals in a single updateNode operation for efficiency' 'Batch multiple property removals in a single updateNode operation for efficiency',
'Prefer patchNodeField over __patch_find_replace for strict error handling — patchNodeField errors on not-found and detects ambiguous matches',
'Use replaceAll: true in patchNodeField when you want to replace all occurrences of a string',
'Use regex: true in patchNodeField for pattern-based replacements (e.g., whitespace-insensitive matching)'
], ],
pitfalls: [ pitfalls: [
'**REQUIRES N8N_API_URL and N8N_API_KEY environment variables** - will not work without n8n API access', '**REQUIRES N8N_API_URL and N8N_API_KEY environment variables** - will not work without n8n API access',
@@ -420,6 +432,9 @@ n8n_update_partial_workflow({
'**Corrupted workflows beyond repair**: Workflows in paradoxical states (API returns corrupt, API rejects updates) cannot be fixed via API - must be recreated', '**Corrupted workflows beyond repair**: Workflows in paradoxical states (API returns corrupt, API rejects updates) cannot be fixed via API - must be recreated',
'**__patch_find_replace for code edits**: Instead of replacing entire code blocks, use `{"parameters.jsCode": {"__patch_find_replace": [{"find": "old text", "replace": "new text"}]}}` to surgically edit string properties', '**__patch_find_replace for code edits**: Instead of replacing entire code blocks, use `{"parameters.jsCode": {"__patch_find_replace": [{"find": "old text", "replace": "new text"}]}}` to surgically edit string properties',
'__patch_find_replace replaces the FIRST occurrence of each find string. Patches are applied sequentially — order matters', '__patch_find_replace replaces the FIRST occurrence of each find string. Patches are applied sequentially — order matters',
'**patchNodeField is strict**: it ERRORS if the find string is not found (unlike __patch_find_replace which only warns)',
'**patchNodeField detects ambiguity**: if find matches multiple times, it ERRORS unless replaceAll: true is set',
'When using regex: true in patchNodeField, escape special regex characters (., *, +, etc.) if you want literal matching',
'To remove a property, set it to null in the updates object', 'To remove a property, set it to null in the updates object',
'When properties are mutually exclusive (e.g., continueOnFail and onError), setting only the new property will fail - you must remove the old one with null', 'When properties are mutually exclusive (e.g., continueOnFail and onError), setting only the new property will fail - you must remove the old one with null',
'Removing a required property may cause validation errors - check node documentation first', 'Removing a required property may cause validation errors - check node documentation first',

View File

@@ -1 +1 @@
{"version":3,"file":"n8n-update-partial-workflow.js","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/workflow_management/n8n-update-partial-workflow.ts"],"names":[],"mappings":"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"} {"version":3,"file":"n8n-update-partial-workflow.js","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/workflow_management/n8n-update-partial-workflow.ts"],"names":[],"mappings":"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"}

View File

@@ -141,7 +141,7 @@ exports.n8nManagementTools = [
}, },
{ {
name: 'n8n_update_partial_workflow', name: 'n8n_update_partial_workflow',
description: `Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, moveNode, enable/disableNode, addConnection, removeConnection, updateSettings, updateName, add/removeTag, activate/deactivateWorkflow, transferWorkflow. See tools_documentation("n8n_update_partial_workflow", "full") for details.`, description: `Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, patchNodeField, moveNode, enable/disableNode, addConnection, removeConnection, updateSettings, updateName, add/removeTag, activate/deactivateWorkflow, transferWorkflow. See tools_documentation("n8n_update_partial_workflow", "full") for details.`,
inputSchema: { inputSchema: {
type: 'object', type: 'object',
additionalProperties: true, additionalProperties: true,

File diff suppressed because one or more lines are too long

View File

@@ -14,6 +14,7 @@ export declare class WorkflowDiffEngine {
private validateAddNode; private validateAddNode;
private validateRemoveNode; private validateRemoveNode;
private validateUpdateNode; private validateUpdateNode;
private validatePatchNodeField;
private validateMoveNode; private validateMoveNode;
private validateToggleNode; private validateToggleNode;
private validateAddConnection; private validateAddConnection;
@@ -22,6 +23,7 @@ export declare class WorkflowDiffEngine {
private applyAddNode; private applyAddNode;
private applyRemoveNode; private applyRemoveNode;
private applyUpdateNode; private applyUpdateNode;
private applyPatchNodeField;
private applyMoveNode; private applyMoveNode;
private applyEnableNode; private applyEnableNode;
private applyDisableNode; private applyDisableNode;

View File

@@ -1 +1 @@
{"version":3,"file":"workflow-diff-engine.d.ts","sourceRoot":"","sources":["../../src/services/workflow-diff-engine.ts"],"names":[],"mappings":"AAMA,OAAO,EAEL,mBAAmB,EACnB,kBAAkB,EAuBnB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,QAAQ,EAAoC,MAAM,kBAAkB,CAAC;AAY9E,qBAAa,kBAAkB;IAE7B,OAAO,CAAC,SAAS,CAAkC;IAEnD,OAAO,CAAC,QAAQ,CAAqC;IAErD,OAAO,CAAC,eAAe,CAAqB;IAE5C,OAAO,CAAC,gBAAgB,CAAqB;IAE7C,OAAO,CAAC,SAAS,CAAgB;IACjC,OAAO,CAAC,YAAY,CAAgB;IAEpC,OAAO,CAAC,mBAAmB,CAAqB;IAK1C,SAAS,CACb,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,kBAAkB,CAAC;IAgO9B,OAAO,CAAC,iBAAiB;IA0CzB,OAAO,CAAC,cAAc;IA4DtB,OAAO,CAAC,eAAe;IAwBvB,OAAO,CAAC,kBAAkB;IAuB1B,OAAO,CAAC,kBAAkB;IA6D1B,OAAO,CAAC,gBAAgB;IAQxB,OAAO,CAAC,kBAAkB;IAU1B,OAAO,CAAC,qBAAqB;IAkD7B,OAAO,CAAC,wBAAwB;IA6ChC,OAAO,CAAC,wBAAwB;IAmDhC,OAAO,CAAC,YAAY;IA4BpB,OAAO,CAAC,eAAe;IAwCvB,OAAO,CAAC,eAAe;IA6CvB,OAAO,CAAC,aAAa;IAOrB,OAAO,CAAC,eAAe;IAOvB,OAAO,CAAC,gBAAgB;IAWxB,OAAO,CAAC,sBAAsB;IA0D9B,OAAO,CAAC,kBAAkB;IAiD1B,OAAO,CAAC,qBAAqB;IAuC7B,OAAO,CAAC,qBAAqB;IA0B7B,OAAO,CAAC,mBAAmB;IAW3B,OAAO,CAAC,eAAe;IAIvB,OAAO,CAAC,WAAW;IAYnB,OAAO,CAAC,cAAc;IAatB,OAAO,CAAC,wBAAwB;IAchC,OAAO,CAAC,0BAA0B;IAMlC,OAAO,CAAC,qBAAqB;IAM7B,OAAO,CAAC,uBAAuB;IAO/B,OAAO,CAAC,wBAAwB;IAOhC,OAAO,CAAC,qBAAqB;IAK7B,OAAO,CAAC,6BAA6B;IAKrC,OAAO,CAAC,0BAA0B;IA0BlC,OAAO,CAAC,0BAA0B;IA+ElC,OAAO,CAAC,uBAAuB;IAe/B,OAAO,CAAC,0BAA0B;IAmElC,OAAO,CAAC,iBAAiB;IAkBzB,OAAO,CAAC,QAAQ;IAsChB,OAAO,CAAC,uBAAuB;IAW/B,OAAO,CAAC,iBAAiB;IAUzB,OAAO,CAAC,iBAAiB;CAoB1B"} {"version":3,"file":"workflow-diff-engine.d.ts","sourceRoot":"","sources":["../../src/services/workflow-diff-engine.ts"],"names":[],"mappings":"AAMA,OAAO,EAEL,mBAAmB,EACnB,kBAAkB,EAwBnB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,QAAQ,EAAoC,MAAM,kBAAkB,CAAC;AA6D9E,qBAAa,kBAAkB;IAE7B,OAAO,CAAC,SAAS,CAAkC;IAEnD,OAAO,CAAC,QAAQ,CAAqC;IAErD,OAAO,CAAC,eAAe,CAAqB;IAE5C,OAAO,CAAC,gBAAgB,CAAqB;IAE7C,OAAO,CAAC,SAAS,CAAgB;IACjC,OAAO,CAAC,YAAY,CAAgB;IAEpC,OAAO,CAAC,mBAAmB,CAAqB;IAK1C,SAAS,CACb,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,kBAAkB,CAAC;IAgO9B,OAAO,CAAC,iBAAiB;IA4CzB,OAAO,CAAC,cAAc;IA+DtB,OAAO,CAAC,eAAe;IAwBvB,OAAO,CAAC,kBAAkB;IAuB1B,OAAO,CAAC,kBAAkB;IA6D1B,OAAO,CAAC,sBAAsB;IAuE9B,OAAO,CAAC,gBAAgB;IAQxB,OAAO,CAAC,kBAAkB;IAU1B,OAAO,CAAC,qBAAqB;IAkD7B,OAAO,CAAC,wBAAwB;IA6ChC,OAAO,CAAC,wBAAwB;IAmDhC,OAAO,CAAC,YAAY;IA4BpB,OAAO,CAAC,eAAe;IAwCvB,OAAO,CAAC,eAAe;IA6CvB,OAAO,CAAC,mBAAmB;IAgE3B,OAAO,CAAC,aAAa;IAOrB,OAAO,CAAC,eAAe;IAOvB,OAAO,CAAC,gBAAgB;IAWxB,OAAO,CAAC,sBAAsB;IA0D9B,OAAO,CAAC,kBAAkB;IAiD1B,OAAO,CAAC,qBAAqB;IAuC7B,OAAO,CAAC,qBAAqB;IA0B7B,OAAO,CAAC,mBAAmB;IAW3B,OAAO,CAAC,eAAe;IAIvB,OAAO,CAAC,WAAW;IAYnB,OAAO,CAAC,cAAc;IAatB,OAAO,CAAC,wBAAwB;IAchC,OAAO,CAAC,0BAA0B;IAMlC,OAAO,CAAC,qBAAqB;IAM7B,OAAO,CAAC,uBAAuB;IAO/B,OAAO,CAAC,wBAAwB;IAOhC,OAAO,CAAC,qBAAqB;IAK7B,OAAO,CAAC,6BAA6B;IAKrC,OAAO,CAAC,0BAA0B;IA0BlC,OAAO,CAAC,0BAA0B;IA+ElC,OAAO,CAAC,uBAAuB;IAe/B,OAAO,CAAC,0BAA0B;IAmElC,OAAO,CAAC,iBAAiB;IAkBzB,OAAO,CAAC,QAAQ;IAsChB,OAAO,CAAC,uBAAuB;IAW/B,OAAO,CAAC,iBAAiB;IAWzB,OAAO,CAAC,iBAAiB;CAyB1B"}

View File

@@ -6,6 +6,39 @@ const logger_1 = require("../utils/logger");
const node_sanitizer_1 = require("./node-sanitizer"); const node_sanitizer_1 = require("./node-sanitizer");
const node_type_utils_1 = require("../utils/node-type-utils"); const node_type_utils_1 = require("../utils/node-type-utils");
const logger = new logger_1.Logger({ prefix: '[WorkflowDiffEngine]' }); const logger = new logger_1.Logger({ prefix: '[WorkflowDiffEngine]' });
const PATCH_LIMITS = {
MAX_PATCHES: 50,
MAX_REGEX_LENGTH: 500,
MAX_FIELD_SIZE_REGEX: 512 * 1024,
};
const DANGEROUS_PATH_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
function isUnsafeRegex(pattern) {
const nestedQuantifier = /\([^)]*[+*][^)]*\)[+*{]/;
if (nestedQuantifier.test(pattern))
return true;
const overlappingAlternation = /\([^)]*\|[^)]*\)[+*{]/;
if (overlappingAlternation.test(pattern)) {
const match = pattern.match(/\(([^)]*)\|([^)]*)\)[+*{]/);
if (match) {
const [, left, right] = match;
const broadClasses = ['.', '\\w', '\\d', '\\s', '\\S', '\\W', '\\D', '[^'];
const leftHasBroad = broadClasses.some(c => left.includes(c));
const rightHasBroad = broadClasses.some(c => right.includes(c));
if (leftHasBroad && rightHasBroad)
return true;
}
}
return false;
}
function countOccurrences(str, search) {
let count = 0;
let pos = 0;
while ((pos = str.indexOf(search, pos)) !== -1) {
count++;
pos += search.length;
}
return count;
}
class WorkflowDiffEngine { class WorkflowDiffEngine {
constructor() { constructor() {
this.renameMap = new Map(); this.renameMap = new Map();
@@ -25,7 +58,7 @@ class WorkflowDiffEngine {
this.tagsToRemove = []; this.tagsToRemove = [];
this.transferToProjectId = undefined; this.transferToProjectId = undefined;
const workflowCopy = JSON.parse(JSON.stringify(workflow)); const workflowCopy = JSON.parse(JSON.stringify(workflow));
const nodeOperationTypes = ['addNode', 'removeNode', 'updateNode', 'moveNode', 'enableNode', 'disableNode']; const nodeOperationTypes = ['addNode', 'removeNode', 'updateNode', 'patchNodeField', 'moveNode', 'enableNode', 'disableNode'];
const nodeOperations = []; const nodeOperations = [];
const otherOperations = []; const otherOperations = [];
request.operations.forEach((operation, index) => { request.operations.forEach((operation, index) => {
@@ -213,6 +246,8 @@ class WorkflowDiffEngine {
return this.validateRemoveNode(workflow, operation); return this.validateRemoveNode(workflow, operation);
case 'updateNode': case 'updateNode':
return this.validateUpdateNode(workflow, operation); return this.validateUpdateNode(workflow, operation);
case 'patchNodeField':
return this.validatePatchNodeField(workflow, operation);
case 'moveNode': case 'moveNode':
return this.validateMoveNode(workflow, operation); return this.validateMoveNode(workflow, operation);
case 'enableNode': case 'enableNode':
@@ -254,6 +289,9 @@ class WorkflowDiffEngine {
case 'updateNode': case 'updateNode':
this.applyUpdateNode(workflow, operation); this.applyUpdateNode(workflow, operation);
break; break;
case 'patchNodeField':
this.applyPatchNodeField(workflow, operation);
break;
case 'moveNode': case 'moveNode':
this.applyMoveNode(workflow, operation); this.applyMoveNode(workflow, operation);
break; break;
@@ -375,6 +413,63 @@ class WorkflowDiffEngine {
} }
return null; return null;
} }
validatePatchNodeField(workflow, operation) {
if (!operation.nodeId && !operation.nodeName) {
return `patchNodeField requires either "nodeId" or "nodeName"`;
}
if (!operation.fieldPath || typeof operation.fieldPath !== 'string') {
return `patchNodeField requires a "fieldPath" string (e.g., "parameters.jsCode")`;
}
const pathSegments = operation.fieldPath.split('.');
if (pathSegments.some(k => DANGEROUS_PATH_KEYS.has(k))) {
return `patchNodeField: fieldPath "${operation.fieldPath}" contains a forbidden key (__proto__, constructor, or prototype)`;
}
if (!Array.isArray(operation.patches) || operation.patches.length === 0) {
return `patchNodeField requires a non-empty "patches" array of {find, replace} objects`;
}
if (operation.patches.length > PATCH_LIMITS.MAX_PATCHES) {
return `patchNodeField: too many patches (${operation.patches.length}). Maximum is ${PATCH_LIMITS.MAX_PATCHES} per operation. Split into multiple operations if needed.`;
}
for (let i = 0; i < operation.patches.length; i++) {
const patch = operation.patches[i];
if (!patch || typeof patch.find !== 'string' || typeof patch.replace !== 'string') {
return `Invalid patch entry at index ${i}: each entry must have "find" (string) and "replace" (string)`;
}
if (patch.find.length === 0) {
return `Invalid patch entry at index ${i}: "find" must not be empty`;
}
if (patch.regex) {
if (patch.find.length > PATCH_LIMITS.MAX_REGEX_LENGTH) {
return `Regex pattern at patch index ${i} is too long (${patch.find.length} chars). Maximum is ${PATCH_LIMITS.MAX_REGEX_LENGTH} characters.`;
}
try {
new RegExp(patch.find);
}
catch (e) {
return `Invalid regex pattern at patch index ${i}: ${e instanceof Error ? e.message : 'invalid regex'}`;
}
if (isUnsafeRegex(patch.find)) {
return `Potentially unsafe regex pattern at patch index ${i}: nested quantifiers or overlapping alternations can cause excessive backtracking. Simplify the pattern or use literal matching (regex: false).`;
}
}
}
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
if (!node) {
return this.formatNodeNotFoundError(workflow, operation.nodeId || operation.nodeName || '', 'patchNodeField');
}
const currentValue = this.getNestedProperty(node, operation.fieldPath);
if (currentValue === undefined) {
return `Cannot apply patchNodeField to "${operation.fieldPath}": property does not exist on node "${node.name}"`;
}
if (typeof currentValue !== 'string') {
return `Cannot apply patchNodeField to "${operation.fieldPath}": current value is ${typeof currentValue}, expected string`;
}
const hasRegex = operation.patches.some(p => p.regex);
if (hasRegex && typeof currentValue === 'string' && currentValue.length > PATCH_LIMITS.MAX_FIELD_SIZE_REGEX) {
return `Field "${operation.fieldPath}" is too large for regex operations (${Math.round(currentValue.length / 1024)}KB). Maximum is ${PATCH_LIMITS.MAX_FIELD_SIZE_REGEX / 1024}KB. Use literal matching (regex: false) for large fields.`;
}
return null;
}
validateMoveNode(workflow, operation) { validateMoveNode(workflow, operation) {
const node = this.findNode(workflow, operation.nodeId, operation.nodeName); const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
if (!node) { if (!node) {
@@ -586,6 +681,51 @@ class WorkflowDiffEngine {
const sanitized = (0, node_sanitizer_1.sanitizeNode)(node); const sanitized = (0, node_sanitizer_1.sanitizeNode)(node);
Object.assign(node, sanitized); Object.assign(node, sanitized);
} }
applyPatchNodeField(workflow, operation) {
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
if (!node)
return;
this.modifiedNodeIds.add(node.id);
let current = this.getNestedProperty(node, operation.fieldPath);
for (let i = 0; i < operation.patches.length; i++) {
const patch = operation.patches[i];
if (patch.regex) {
const globalRegex = new RegExp(patch.find, 'g');
const matches = current.match(globalRegex);
if (!matches || matches.length === 0) {
throw new Error(`patchNodeField: regex pattern "${patch.find}" not found in "${operation.fieldPath}" (patch index ${i}). ` +
`Use n8n_get_workflow to inspect the current value.`);
}
if (matches.length > 1 && !patch.replaceAll) {
throw new Error(`patchNodeField: regex pattern "${patch.find}" matches ${matches.length} times in "${operation.fieldPath}" (patch index ${i}). ` +
`Set "replaceAll": true to replace all occurrences, or refine the pattern to match exactly once.`);
}
const regex = patch.replaceAll ? globalRegex : new RegExp(patch.find);
current = current.replace(regex, patch.replace);
}
else {
const occurrences = countOccurrences(current, patch.find);
if (occurrences === 0) {
throw new Error(`patchNodeField: "${patch.find.substring(0, 80)}" not found in "${operation.fieldPath}" (patch index ${i}). ` +
`Ensure the find string exactly matches the current content (including whitespace and newlines). ` +
`Use n8n_get_workflow to inspect the current value.`);
}
if (occurrences > 1 && !patch.replaceAll) {
throw new Error(`patchNodeField: "${patch.find.substring(0, 80)}" found ${occurrences} times in "${operation.fieldPath}" (patch index ${i}). ` +
`Set "replaceAll": true to replace all occurrences, or use a more specific find string that matches exactly once.`);
}
if (patch.replaceAll) {
current = current.split(patch.find).join(patch.replace);
}
else {
current = current.replace(patch.find, patch.replace);
}
}
}
this.setNestedProperty(node, operation.fieldPath, current);
const sanitized = (0, node_sanitizer_1.sanitizeNode)(node);
Object.assign(node, sanitized);
}
applyMoveNode(workflow, operation) { applyMoveNode(workflow, operation) {
const node = this.findNode(workflow, operation.nodeId, operation.nodeName); const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
if (!node) if (!node)
@@ -924,6 +1064,8 @@ class WorkflowDiffEngine {
const keys = path.split('.'); const keys = path.split('.');
let current = obj; let current = obj;
for (const key of keys) { for (const key of keys) {
if (DANGEROUS_PATH_KEYS.has(key))
return undefined;
if (current == null || typeof current !== 'object') if (current == null || typeof current !== 'object')
return undefined; return undefined;
current = current[key]; current = current[key];
@@ -933,6 +1075,9 @@ class WorkflowDiffEngine {
setNestedProperty(obj, path, value) { setNestedProperty(obj, path, value) {
const keys = path.split('.'); const keys = path.split('.');
let current = obj; let current = obj;
if (keys.some(k => DANGEROUS_PATH_KEYS.has(k))) {
throw new Error(`Invalid property path: "${path}" contains a forbidden key`);
}
for (let i = 0; i < keys.length - 1; i++) { for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]; const key = keys[i];
if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) { if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) {

File diff suppressed because one or more lines are too long

View File

@@ -40,6 +40,18 @@ export interface DisableNodeOperation extends DiffOperation {
nodeId?: string; nodeId?: string;
nodeName?: string; nodeName?: string;
} }
export interface PatchNodeFieldOperation extends DiffOperation {
type: 'patchNodeField';
nodeId?: string;
nodeName?: string;
fieldPath: string;
patches: Array<{
find: string;
replace: string;
replaceAll?: boolean;
regex?: boolean;
}>;
}
export interface AddConnectionOperation extends DiffOperation { export interface AddConnectionOperation extends DiffOperation {
type: 'addConnection'; type: 'addConnection';
source: string; source: string;
@@ -114,7 +126,7 @@ export interface ReplaceConnectionsOperation extends DiffOperation {
}; };
}; };
} }
export type WorkflowDiffOperation = AddNodeOperation | RemoveNodeOperation | UpdateNodeOperation | MoveNodeOperation | EnableNodeOperation | DisableNodeOperation | AddConnectionOperation | RemoveConnectionOperation | RewireConnectionOperation | UpdateSettingsOperation | UpdateNameOperation | AddTagOperation | RemoveTagOperation | ActivateWorkflowOperation | DeactivateWorkflowOperation | CleanStaleConnectionsOperation | ReplaceConnectionsOperation | TransferWorkflowOperation; export type WorkflowDiffOperation = AddNodeOperation | RemoveNodeOperation | UpdateNodeOperation | PatchNodeFieldOperation | MoveNodeOperation | EnableNodeOperation | DisableNodeOperation | AddConnectionOperation | RemoveConnectionOperation | RewireConnectionOperation | UpdateSettingsOperation | UpdateNameOperation | AddTagOperation | RemoveTagOperation | ActivateWorkflowOperation | DeactivateWorkflowOperation | CleanStaleConnectionsOperation | ReplaceConnectionsOperation | TransferWorkflowOperation;
export interface WorkflowDiffRequest { export interface WorkflowDiffRequest {
id: string; id: string;
operations: WorkflowDiffOperation[]; operations: WorkflowDiffOperation[];
@@ -149,7 +161,7 @@ export interface NodeReference {
id?: string; id?: string;
name?: string; name?: string;
} }
export declare function isNodeOperation(op: WorkflowDiffOperation): op is AddNodeOperation | RemoveNodeOperation | UpdateNodeOperation | MoveNodeOperation | EnableNodeOperation | DisableNodeOperation; export declare function isNodeOperation(op: WorkflowDiffOperation): op is AddNodeOperation | RemoveNodeOperation | UpdateNodeOperation | PatchNodeFieldOperation | MoveNodeOperation | EnableNodeOperation | DisableNodeOperation;
export declare function isConnectionOperation(op: WorkflowDiffOperation): op is AddConnectionOperation | RemoveConnectionOperation | RewireConnectionOperation | CleanStaleConnectionsOperation | ReplaceConnectionsOperation; export declare function isConnectionOperation(op: WorkflowDiffOperation): op is AddConnectionOperation | RemoveConnectionOperation | RewireConnectionOperation | CleanStaleConnectionsOperation | ReplaceConnectionsOperation;
export declare function isMetadataOperation(op: WorkflowDiffOperation): op is UpdateSettingsOperation | UpdateNameOperation | AddTagOperation | RemoveTagOperation; export declare function isMetadataOperation(op: WorkflowDiffOperation): op is UpdateSettingsOperation | UpdateNameOperation | AddTagOperation | RemoveTagOperation;
//# sourceMappingURL=workflow-diff.d.ts.map //# sourceMappingURL=workflow-diff.d.ts.map

View File

@@ -1 +1 @@
{"version":3,"file":"workflow-diff.d.ts","sourceRoot":"","sources":["../../src/types/workflow-diff.ts"],"names":[],"mappings":"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"} {"version":3,"file":"workflow-diff.d.ts","sourceRoot":"","sources":["../../src/types/workflow-diff.ts"],"names":[],"mappings":"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"}

View File

@@ -4,7 +4,7 @@ exports.isNodeOperation = isNodeOperation;
exports.isConnectionOperation = isConnectionOperation; exports.isConnectionOperation = isConnectionOperation;
exports.isMetadataOperation = isMetadataOperation; exports.isMetadataOperation = isMetadataOperation;
function isNodeOperation(op) { function isNodeOperation(op) {
return ['addNode', 'removeNode', 'updateNode', 'moveNode', 'enableNode', 'disableNode'].includes(op.type); return ['addNode', 'removeNode', 'updateNode', 'patchNodeField', 'moveNode', 'enableNode', 'disableNode'].includes(op.type);
} }
function isConnectionOperation(op) { function isConnectionOperation(op) {
return ['addConnection', 'removeConnection', 'rewireConnection', 'cleanStaleConnections', 'replaceConnections'].includes(op.type); return ['addConnection', 'removeConnection', 'rewireConnection', 'cleanStaleConnections', 'replaceConnections'].includes(op.type);

View File

@@ -1 +1 @@
{"version":3,"file":"workflow-diff.js","sourceRoot":"","sources":["../../src/types/workflow-diff.ts"],"names":[],"mappings":";;AAkNA,0CAIC;AAED,sDAGC;AAED,kDAGC;AAdD,SAAgB,eAAe,CAAC,EAAyB;IAGvD,OAAO,CAAC,SAAS,EAAE,YAAY,EAAE,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,aAAa,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;AAC5G,CAAC;AAED,SAAgB,qBAAqB,CAAC,EAAyB;IAE7D,OAAO,CAAC,eAAe,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,oBAAoB,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;AACpI,CAAC;AAED,SAAgB,mBAAmB,CAAC,EAAyB;IAE3D,OAAO,CAAC,gBAAgB,EAAE,YAAY,EAAE,QAAQ,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;AACnF,CAAC"} {"version":3,"file":"workflow-diff.js","sourceRoot":"","sources":["../../src/types/workflow-diff.ts"],"names":[],"mappings":";;AAgOA,0CAIC;AAED,sDAGC;AAED,kDAGC;AAdD,SAAgB,eAAe,CAAC,EAAyB;IAGvD,OAAO,CAAC,SAAS,EAAE,YAAY,EAAE,YAAY,EAAE,gBAAgB,EAAE,UAAU,EAAE,YAAY,EAAE,aAAa,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;AAC9H,CAAC;AAED,SAAgB,qBAAqB,CAAC,EAAyB;IAE7D,OAAO,CAAC,eAAe,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,oBAAoB,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;AACpI,CAAC;AAED,SAAgB,mBAAmB,CAAC,EAAyB;IAE3D,OAAO,CAAC,gBAAgB,EAAE,YAAY,EAAE,QAAQ,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;AACnF,CAAC"}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "n8n-mcp", "name": "n8n-mcp",
"version": "2.44.1", "version": "2.46.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "n8n-mcp", "name": "n8n-mcp",
"version": "2.44.1", "version": "2.46.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "1.28.0", "@modelcontextprotocol/sdk": "1.28.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "n8n-mcp", "name": "n8n-mcp",
"version": "2.45.1", "version": "2.46.1",
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)", "description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "n8n-mcp-runtime", "name": "n8n-mcp-runtime",
"version": "2.45.1", "version": "2.46.0",
"description": "n8n MCP Server Runtime Dependencies Only", "description": "n8n MCP Server Runtime Dependencies Only",
"private": true, "private": true,
"dependencies": { "dependencies": {

View File

@@ -46,15 +46,6 @@ interface MultiTenantHeaders {
const MAX_SESSIONS = Math.max(1, parseInt(process.env.N8N_MCP_MAX_SESSIONS || '100', 10)); const MAX_SESSIONS = Math.max(1, parseInt(process.env.N8N_MCP_MAX_SESSIONS || '100', 10));
const SESSION_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes const SESSION_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
interface Session {
server: N8NDocumentationMCPServer;
transport: StreamableHTTPServerTransport | SSEServerTransport;
lastAccess: Date;
sessionId: string;
initialized: boolean;
isSSE: boolean;
}
interface SessionMetrics { interface SessionMetrics {
totalSessions: number; totalSessions: number;
activeSessions: number; activeSessions: number;
@@ -104,12 +95,12 @@ export interface SingleSessionHTTPServerOptions {
export class SingleSessionHTTPServer { export class SingleSessionHTTPServer {
// Map to store transports by session ID (following SDK pattern) // Map to store transports by session ID (following SDK pattern)
private transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; // Stores both StreamableHTTP and SSE transports; use instanceof to discriminate
private transports: { [sessionId: string]: StreamableHTTPServerTransport | SSEServerTransport } = {};
private servers: { [sessionId: string]: N8NDocumentationMCPServer } = {}; private servers: { [sessionId: string]: N8NDocumentationMCPServer } = {};
private sessionMetadata: { [sessionId: string]: { lastAccess: Date; createdAt: Date } } = {}; private sessionMetadata: { [sessionId: string]: { lastAccess: Date; createdAt: Date } } = {};
private sessionContexts: { [sessionId: string]: InstanceContext | undefined } = {}; private sessionContexts: { [sessionId: string]: InstanceContext | undefined } = {};
private contextSwitchLocks: Map<string, Promise<void>> = new Map(); private contextSwitchLocks: Map<string, Promise<void>> = new Map();
private session: Session | null = null; // Keep for SSE compatibility
private consoleManager = new ConsoleManager(); private consoleManager = new ConsoleManager();
private expressServer: any; private expressServer: any;
// Session timeout — configurable via SESSION_TIMEOUT_MINUTES environment variable // Session timeout — configurable via SESSION_TIMEOUT_MINUTES environment variable
@@ -319,6 +310,49 @@ export class SingleSessionHTTPServer {
} }
} }
/**
* Authenticate a request by validating the Bearer token.
* Returns true if authentication succeeds, false if it fails
* (and the response has already been sent with a 401 status).
*/
private authenticateRequest(req: express.Request, res: express.Response): boolean {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
const reason = !authHeader ? 'no_auth_header' : 'invalid_auth_format';
logger.warn('Authentication failed', {
ip: req.ip,
userAgent: req.get('user-agent'),
reason
});
res.status(401).json({
jsonrpc: '2.0',
error: { code: -32001, message: 'Unauthorized' },
id: null
});
return false;
}
const token = authHeader.slice(7).trim();
const isValid = this.authToken && AuthManager.timingSafeCompare(token, this.authToken);
if (!isValid) {
logger.warn('Authentication failed: Invalid token', {
ip: req.ip,
userAgent: req.get('user-agent'),
reason: 'invalid_token'
});
res.status(401).json({
jsonrpc: '2.0',
error: { code: -32001, message: 'Unauthorized' },
id: null
});
return false;
}
return true;
}
/** /**
* Switch session context with locking to prevent race conditions * Switch session context with locking to prevent race conditions
*/ */
@@ -636,7 +670,22 @@ export class SingleSessionHTTPServer {
// For non-initialize requests: reuse existing transport for this session // For non-initialize requests: reuse existing transport for this session
logger.info('handleRequest: Reusing existing transport for session', { sessionId }); logger.info('handleRequest: Reusing existing transport for session', { sessionId });
transport = this.transports[sessionId];
// Guard: reject SSE transports on the StreamableHTTP path
if (this.transports[sessionId] instanceof SSEServerTransport) {
logger.warn('handleRequest: SSE session used on StreamableHTTP endpoint', { sessionId });
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Session uses SSE transport. Send messages to POST /messages?sessionId=<id> instead.'
},
id: req.body?.id || null
});
return;
}
transport = this.transports[sessionId] as StreamableHTTPServerTransport;
// TOCTOU guard: session may have been removed between the check above and here // TOCTOU guard: session may have been removed between the check above and here
if (!transport) { if (!transport) {
@@ -751,73 +800,47 @@ export class SingleSessionHTTPServer {
/** /**
* Reset the session for SSE - clean up old and create new SSE transport * Create a new SSE session and store it in the shared transports map.
* Following SDK pattern: SSE uses /messages endpoint, separate from /mcp.
*/ */
private async resetSessionSSE(res: express.Response): Promise<void> { private async createSSESession(res: express.Response): Promise<void> {
// Clean up old session if exists if (!this.canCreateSession()) {
if (this.session) { logger.warn('SSE session creation rejected: session limit reached', {
const sessionId = this.session.sessionId; currentSessions: this.getActiveSessionCount(),
logger.info('Closing previous session for SSE', { sessionId }); maxSessions: MAX_SESSIONS
// Close server first to free resources (database, cache timer, etc.)
// This mirrors the cleanup pattern in removeSession() (issue #542)
// Handle server close errors separately so transport close still runs
if (this.session.server && typeof this.session.server.close === 'function') {
try {
await this.session.server.close();
} catch (serverError) {
logger.warn('Error closing server for SSE session', { sessionId, error: serverError });
}
}
// Close transport last - always attempt even if server.close() failed
try {
await this.session.transport.close();
} catch (transportError) {
logger.warn('Error closing transport for SSE session', { sessionId, error: transportError });
}
}
try {
// Create new session
logger.info('Creating new N8NDocumentationMCPServer for SSE...');
const server = new N8NDocumentationMCPServer(undefined, undefined, {
generateWorkflowHandler: this.generateWorkflowHandler,
}); });
throw new Error(`Session limit reached (${MAX_SESSIONS})`);
// Generate cryptographically secure session ID
const sessionId = uuidv4();
logger.info('Creating SSEServerTransport...');
const transport = new SSEServerTransport('/mcp', res);
logger.info('Connecting server to SSE transport...');
await server.connect(transport);
// Note: server.connect() automatically calls transport.start(), so we don't need to call it again
this.session = {
server,
transport,
lastAccess: new Date(),
sessionId,
initialized: false,
isSSE: true
};
logger.info('Created new SSE session successfully', { sessionId: this.session.sessionId });
} catch (error) {
logger.error('Failed to create SSE session:', error);
throw error;
} }
}
// Note: SSE sessions do not support multi-tenant context.
/** // The SaaS backend uses StreamableHTTP exclusively.
* Check if current session is expired const server = new N8NDocumentationMCPServer(undefined, undefined, {
*/ generateWorkflowHandler: this.generateWorkflowHandler,
private isExpired(): boolean { });
if (!this.session) return true;
return Date.now() - this.session.lastAccess.getTime() > this.sessionTimeout; const transport = new SSEServerTransport('/messages', res);
// Use the SDK-assigned session ID — the client receives this via the SSE
// `endpoint` event and sends it back as ?sessionId on POST /messages.
const sessionId = transport.sessionId;
this.transports[sessionId] = transport;
this.servers[sessionId] = server;
this.sessionMetadata[sessionId] = {
lastAccess: new Date(),
createdAt: new Date()
};
// Clean up on SSE disconnect
res.on('close', () => {
logger.info('SSE connection closed by client', { sessionId });
this.removeSession(sessionId, 'sse_disconnect').catch(err => {
logger.warn('Error cleaning up SSE session on disconnect', { sessionId, error: err });
});
});
await server.connect(transport);
logger.info('SSE session created', { sessionId, transport: 'SSEServerTransport' });
} }
/** /**
@@ -913,7 +936,7 @@ export class SingleSessionHTTPServer {
authentication: { authentication: {
type: 'Bearer Token', type: 'Bearer Token',
header: 'Authorization: Bearer <token>', header: 'Authorization: Bearer <token>',
required_for: ['POST /mcp'] required_for: ['POST /mcp', 'GET /sse', 'POST /messages']
}, },
documentation: 'https://github.com/czlonkowski/n8n-mcp' documentation: 'https://github.com/czlonkowski/n8n-mcp'
}); });
@@ -948,7 +971,7 @@ export class SingleSessionHTTPServer {
}, },
activeTransports: activeTransports.length, // Legacy field activeTransports: activeTransports.length, // Legacy field
activeServers: activeServers.length, // Legacy field activeServers: activeServers.length, // Legacy field
legacySessionActive: !!this.session, // For SSE compatibility legacySessionActive: false, // Deprecated: SSE now uses shared transports map
memory: { memory: {
used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024), total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
@@ -1005,10 +1028,11 @@ export class SingleSessionHTTPServer {
app.get('/mcp', async (req, res) => { app.get('/mcp', async (req, res) => {
// Handle StreamableHTTP transport requests with new pattern // Handle StreamableHTTP transport requests with new pattern
const sessionId = req.headers['mcp-session-id'] as string | undefined; const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId && this.transports[sessionId]) { const existingTransport = sessionId ? this.transports[sessionId] : undefined;
if (existingTransport && existingTransport instanceof StreamableHTTPServerTransport) {
// Let the StreamableHTTPServerTransport handle the GET request // Let the StreamableHTTPServerTransport handle the GET request
try { try {
await this.transports[sessionId].handleRequest(req, res, undefined); await existingTransport.handleRequest(req, res, undefined);
return; return;
} catch (error) { } catch (error) {
logger.error('StreamableHTTP GET request failed:', error); logger.error('StreamableHTTP GET request failed:', error);
@@ -1016,26 +1040,15 @@ export class SingleSessionHTTPServer {
} }
} }
// Check Accept header for text/event-stream (SSE support) // SSE clients should use GET /sse instead (SDK pattern: separate endpoints)
const accept = req.headers.accept; const accept = req.headers.accept;
if (accept && accept.includes('text/event-stream')) { if (accept && accept.includes('text/event-stream')) {
logger.info('SSE stream request received - establishing SSE connection'); logger.info('SSE request on /mcp redirected to /sse', { ip: req.ip });
res.status(400).json({
try { error: 'SSE transport uses /sse endpoint',
// Create or reset session for SSE message: 'Connect via GET /sse for SSE streaming. POST messages to /messages?sessionId=<id>.',
await this.resetSessionSSE(res); documentation: 'https://github.com/czlonkowski/n8n-mcp'
logger.info('SSE connection established successfully'); });
} catch (error) {
logger.error('Failed to establish SSE connection:', error);
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Failed to establish SSE connection'
},
id: null
});
}
return; return;
} }
@@ -1072,9 +1085,23 @@ export class SingleSessionHTTPServer {
mcp: { mcp: {
method: 'POST', method: 'POST',
path: '/mcp', path: '/mcp',
description: 'Main MCP JSON-RPC endpoint', description: 'Main MCP JSON-RPC endpoint (StreamableHTTP)',
authentication: 'Bearer token required' authentication: 'Bearer token required'
}, },
sse: {
method: 'GET',
path: '/sse',
description: 'DEPRECATED: SSE stream for legacy clients. Migrate to StreamableHTTP (POST /mcp).',
authentication: 'Bearer token required',
deprecated: true
},
messages: {
method: 'POST',
path: '/messages',
description: 'DEPRECATED: Message delivery for SSE sessions. Migrate to StreamableHTTP (POST /mcp).',
authentication: 'Bearer token required',
deprecated: true
},
health: { health: {
method: 'GET', method: 'GET',
path: '/health', path: '/health',
@@ -1092,6 +1119,110 @@ export class SingleSessionHTTPServer {
}); });
}); });
// SECURITY: Rate limiting for authentication endpoints
// Prevents brute force attacks and DoS
// See: https://github.com/czlonkowski/n8n-mcp/issues/265 (HIGH-02)
const authLimiter = rateLimit({
windowMs: parseInt(process.env.AUTH_RATE_LIMIT_WINDOW || '900000'), // 15 minutes
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX || '20'), // 20 authentication attempts per IP
message: {
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Too many authentication attempts. Please try again later.'
},
id: null
},
standardHeaders: true, // Return rate limit info in `RateLimit-*` headers
legacyHeaders: false, // Disable `X-RateLimit-*` headers
skipSuccessfulRequests: true, // Only count failed auth attempts (#617)
handler: (req, res) => {
logger.warn('Rate limit exceeded', {
ip: req.ip,
userAgent: req.get('user-agent'),
event: 'rate_limit'
});
res.status(429).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Too many authentication attempts'
},
id: null
});
}
});
// Legacy SSE stream endpoint (protocol version 2024-11-05)
// DEPRECATED: SSE transport is deprecated in MCP SDK v1.x and removed in v2.x.
// Clients should migrate to StreamableHTTP (POST /mcp). This endpoint will be
// removed in a future major release.
app.get('/sse', authLimiter, async (req: express.Request, res: express.Response): Promise<void> => {
if (!this.authenticateRequest(req, res)) return;
logger.warn('SSE transport is deprecated and will be removed in a future release. Migrate to StreamableHTTP (POST /mcp).', {
ip: req.ip,
userAgent: req.get('user-agent')
});
try {
await this.createSSESession(res);
} catch (error) {
logger.error('Failed to create SSE session:', error);
if (!res.headersSent) {
res.status(error instanceof Error && error.message.includes('Session limit')
? 429 : 500
).json({
error: error instanceof Error ? error.message : 'Failed to establish SSE connection'
});
}
}
});
// SSE message delivery endpoint (receives JSON-RPC messages from SSE clients)
app.post('/messages', authLimiter, jsonParser, async (req: express.Request, res: express.Response): Promise<void> => {
if (!this.authenticateRequest(req, res)) return;
// SSE uses ?sessionId query param (not mcp-session-id header)
const sessionId = req.query.sessionId as string | undefined;
if (!sessionId) {
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32602, message: 'Missing sessionId query parameter' },
id: req.body?.id || null
});
return;
}
const transport = this.transports[sessionId];
if (!transport || !(transport instanceof SSEServerTransport)) {
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32000, message: 'SSE session not found or expired' },
id: req.body?.id || null
});
return;
}
// Update session access time
this.updateSessionAccess(sessionId);
try {
await transport.handlePostMessage(req, res, req.body);
} catch (error) {
logger.error('SSE message handling error', { sessionId, error });
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal error processing SSE message' },
id: req.body?.id || null
});
}
}
});
// Session termination endpoint // Session termination endpoint
app.delete('/mcp', async (req: express.Request, res: express.Response): Promise<void> => { app.delete('/mcp', async (req: express.Request, res: express.Response): Promise<void> => {
const mcpSessionId = req.headers['mcp-session-id'] as string; const mcpSessionId = req.headers['mcp-session-id'] as string;
@@ -1150,40 +1281,6 @@ export class SingleSessionHTTPServer {
} }
}); });
// SECURITY: Rate limiting for authentication endpoint
// Prevents brute force attacks and DoS
// See: https://github.com/czlonkowski/n8n-mcp/issues/265 (HIGH-02)
const authLimiter = rateLimit({
windowMs: parseInt(process.env.AUTH_RATE_LIMIT_WINDOW || '900000'), // 15 minutes
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX || '20'), // 20 authentication attempts per IP
message: {
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Too many authentication attempts. Please try again later.'
},
id: null
},
standardHeaders: true, // Return rate limit info in `RateLimit-*` headers
legacyHeaders: false, // Disable `X-RateLimit-*` headers
handler: (req, res) => {
logger.warn('Rate limit exceeded', {
ip: req.ip,
userAgent: req.get('user-agent'),
event: 'rate_limit'
});
res.status(429).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Too many authentication attempts'
},
id: null
});
}
});
// Main MCP endpoint with authentication and rate limiting // Main MCP endpoint with authentication and rate limiting
app.post('/mcp', authLimiter, jsonParser, async (req: express.Request, res: express.Response): Promise<void> => { app.post('/mcp', authLimiter, jsonParser, async (req: express.Request, res: express.Response): Promise<void> => {
// Log comprehensive debug info about the request // Log comprehensive debug info about the request
@@ -1234,76 +1331,10 @@ export class SingleSessionHTTPServer {
}); });
} }
// Enhanced authentication check with specific logging if (!this.authenticateRequest(req, res)) return;
const authHeader = req.headers.authorization;
// Check if Authorization header is missing
if (!authHeader) {
logger.warn('Authentication failed: Missing Authorization header', {
ip: req.ip,
userAgent: req.get('user-agent'),
reason: 'no_auth_header'
});
res.status(401).json({
jsonrpc: '2.0',
error: {
code: -32001,
message: 'Unauthorized'
},
id: null
});
return;
}
// Check if Authorization header has Bearer prefix
if (!authHeader.startsWith('Bearer ')) {
logger.warn('Authentication failed: Invalid Authorization header format (expected Bearer token)', {
ip: req.ip,
userAgent: req.get('user-agent'),
reason: 'invalid_auth_format',
headerPrefix: authHeader.substring(0, Math.min(authHeader.length, 10)) + '...' // Log first 10 chars for debugging
});
res.status(401).json({
jsonrpc: '2.0',
error: {
code: -32001,
message: 'Unauthorized'
},
id: null
});
return;
}
// Extract token and trim whitespace
const token = authHeader.slice(7).trim();
// SECURITY: Use timing-safe comparison to prevent timing attacks
// See: https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-02)
const isValidToken = this.authToken &&
AuthManager.timingSafeCompare(token, this.authToken);
if (!isValidToken) {
logger.warn('Authentication failed: Invalid token', {
ip: req.ip,
userAgent: req.get('user-agent'),
reason: 'invalid_token'
});
res.status(401).json({
jsonrpc: '2.0',
error: {
code: -32001,
message: 'Unauthorized'
},
id: null
});
return;
}
// Handle request with single session
logger.info('Authentication successful - proceeding to handleRequest', { logger.info('Authentication successful - proceeding to handleRequest', {
hasSession: !!this.session, activeSessions: this.getActiveSessionCount()
sessionType: this.session?.isSSE ? 'SSE' : 'StreamableHTTP',
sessionInitialized: this.session?.initialized
}); });
// Extract instance context from headers if present (for multi-tenant support) // Extract instance context from headers if present (for multi-tenant support)
@@ -1417,6 +1448,7 @@ export class SingleSessionHTTPServer {
console.log(`Session Limits: ${MAX_SESSIONS} max sessions, ${this.sessionTimeout / 1000 / 60}min timeout`); console.log(`Session Limits: ${MAX_SESSIONS} max sessions, ${this.sessionTimeout / 1000 / 60}min timeout`);
console.log(`Health check: ${endpoints.health}`); console.log(`Health check: ${endpoints.health}`);
console.log(`MCP endpoint: ${endpoints.mcp}`); console.log(`MCP endpoint: ${endpoints.mcp}`);
console.log(`SSE endpoint: ${baseUrl}/sse (legacy clients)`);
if (isProduction) { if (isProduction) {
console.log('🔒 Running in PRODUCTION mode - enhanced security enabled'); console.log('🔒 Running in PRODUCTION mode - enhanced security enabled');
@@ -1483,17 +1515,6 @@ export class SingleSessionHTTPServer {
} }
} }
// Clean up legacy session (for SSE compatibility)
if (this.session) {
try {
await this.session.transport.close();
logger.info('Legacy session closed');
} catch (error) {
logger.warn('Error closing legacy session:', error);
}
this.session = null;
}
// Close Express server // Close Express server
if (this.expressServer) { if (this.expressServer) {
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
@@ -1532,25 +1553,9 @@ export class SingleSessionHTTPServer {
}; };
} { } {
const metrics = this.getSessionMetrics(); const metrics = this.getSessionMetrics();
// Legacy SSE session info
if (!this.session) {
return {
active: false,
sessions: {
total: metrics.totalSessions,
active: metrics.activeSessions,
expired: metrics.expiredSessions,
max: MAX_SESSIONS,
sessionIds: Object.keys(this.transports)
}
};
}
return { return {
active: true, active: metrics.activeSessions > 0,
sessionId: this.session.sessionId,
age: Date.now() - this.session.lastAccess.getTime(),
sessions: { sessions: {
total: metrics.totalSessions, total: metrics.totalSessions,
active: metrics.activeSessions, active: metrics.activeSessions,

View File

@@ -33,7 +33,7 @@ function getValidator(repository: NodeRepository): WorkflowValidator {
// Operation types that identify nodes by nodeId/nodeName // Operation types that identify nodes by nodeId/nodeName
const NODE_TARGETING_OPERATIONS = new Set([ const NODE_TARGETING_OPERATIONS = new Set([
'updateNode', 'removeNode', 'moveNode', 'enableNode', 'disableNode' 'updateNode', 'removeNode', 'moveNode', 'enableNode', 'disableNode', 'patchNodeField'
]); ]);
// Zod schema for the diff request // Zod schema for the diff request
@@ -47,6 +47,8 @@ const workflowDiffSchema = z.object({
nodeId: z.string().optional(), nodeId: z.string().optional(),
nodeName: z.string().optional(), nodeName: z.string().optional(),
updates: z.any().optional(), updates: z.any().optional(),
fieldPath: z.string().optional(),
patches: z.any().optional(),
position: z.tuple([z.number(), z.number()]).optional(), position: z.tuple([z.number(), z.number()]).optional(),
// Connection operations // Connection operations
source: z.string().optional(), source: z.string().optional(),
@@ -569,6 +571,8 @@ function inferIntentFromOperations(operations: any[]): string {
return `Remove node ${op.nodeName || op.nodeId || ''}`.trim(); return `Remove node ${op.nodeName || op.nodeId || ''}`.trim();
case 'updateNode': case 'updateNode':
return `Update node ${op.nodeName || op.nodeId || ''}`.trim(); return `Update node ${op.nodeName || op.nodeId || ''}`.trim();
case 'patchNodeField':
return `Patch field on node ${op.nodeName || op.nodeId || ''}`.trim();
case 'addConnection': case 'addConnection':
return `Connect ${op.source || 'node'} to ${op.target || 'node'}`; return `Connect ${op.source || 'node'} to ${op.target || 'node'}`;
case 'removeConnection': case 'removeConnection':
@@ -604,6 +608,10 @@ function inferIntentFromOperations(operations: any[]): string {
const count = opTypes.filter((t) => t === 'updateNode').length; const count = opTypes.filter((t) => t === 'updateNode').length;
summary.push(`update ${count} node${count > 1 ? 's' : ''}`); summary.push(`update ${count} node${count > 1 ? 's' : ''}`);
} }
if (typeSet.has('patchNodeField')) {
const count = opTypes.filter((t) => t === 'patchNodeField').length;
summary.push(`patch ${count} field${count > 1 ? 's' : ''}`);
}
if (typeSet.has('addConnection') || typeSet.has('rewireConnection')) { if (typeSet.has('addConnection') || typeSet.has('rewireConnection')) {
summary.push('modify connections'); summary.push('modify connections');
} }

View File

@@ -4,7 +4,7 @@ export const n8nUpdatePartialWorkflowDoc: ToolDocumentation = {
name: 'n8n_update_partial_workflow', name: 'n8n_update_partial_workflow',
category: 'workflow_management', category: 'workflow_management',
essentials: { essentials: {
description: 'Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, moveNode, enable/disableNode, addConnection, removeConnection, rewireConnection, cleanStaleConnections, replaceConnections, updateSettings, updateName, add/removeTag, activateWorkflow, deactivateWorkflow, transferWorkflow. Supports smart parameters (branch, case) for multi-output nodes. Full support for AI connections (ai_languageModel, ai_tool, ai_memory, ai_embedding, ai_vectorStore, ai_document, ai_textSplitter, ai_outputParser).', description: 'Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, patchNodeField, moveNode, enable/disableNode, addConnection, removeConnection, rewireConnection, cleanStaleConnections, replaceConnections, updateSettings, updateName, add/removeTag, activateWorkflow, deactivateWorkflow, transferWorkflow. Supports smart parameters (branch, case) for multi-output nodes. Full support for AI connections (ai_languageModel, ai_tool, ai_memory, ai_embedding, ai_vectorStore, ai_document, ai_textSplitter, ai_outputParser).',
keyParameters: ['id', 'operations', 'continueOnError'], keyParameters: ['id', 'operations', 'continueOnError'],
example: 'n8n_update_partial_workflow({id: "wf_123", operations: [{type: "rewireConnection", source: "IF", from: "Old", to: "New", branch: "true"}]})', example: 'n8n_update_partial_workflow({id: "wf_123", operations: [{type: "rewireConnection", source: "IF", from: "Old", to: "New", branch: "true"}]})',
performance: 'Fast (50-200ms)', performance: 'Fast (50-200ms)',
@@ -27,14 +27,15 @@ export const n8nUpdatePartialWorkflowDoc: ToolDocumentation = {
] ]
}, },
full: { full: {
description: `Updates workflows using surgical diff operations instead of full replacement. Supports 17 operation types for precise modifications. Operations are validated and applied atomically by default - all succeed or none are applied. description: `Updates workflows using surgical diff operations instead of full replacement. Supports 18 operation types for precise modifications. Operations are validated and applied atomically by default - all succeed or none are applied.
## Available Operations: ## Available Operations:
### Node Operations (6 types): ### Node Operations (7 types):
- **addNode**: Add a new node with name, type, and position (required) - **addNode**: Add a new node with name, type, and position (required)
- **removeNode**: Remove a node by ID or name - **removeNode**: Remove a node by ID or name
- **updateNode**: Update node properties using dot notation (e.g., 'parameters.url') - **updateNode**: Update node properties using dot notation (e.g., 'parameters.url')
- **patchNodeField**: Surgically edit string fields using find/replace patches. Strict mode: errors if find string not found, errors if multiple matches (ambiguity) unless replaceAll is set. Supports replaceAll and regex flags.
- **moveNode**: Change node position [x, y] - **moveNode**: Change node position [x, y]
- **enableNode**: Enable a disabled node - **enableNode**: Enable a disabled node
- **disableNode**: Disable an active node - **disableNode**: Disable an active node
@@ -335,6 +336,11 @@ n8n_update_partial_workflow({
'// Validate before applying\nn8n_update_partial_workflow({id: "bcd", operations: [{type: "removeNode", nodeName: "Old Process"}], validateOnly: true})', '// Validate before applying\nn8n_update_partial_workflow({id: "bcd", operations: [{type: "removeNode", nodeName: "Old Process"}], validateOnly: true})',
'// Surgically edit code using __patch_find_replace (avoids replacing entire code block)\nn8n_update_partial_workflow({id: "pfr1", operations: [{type: "updateNode", nodeName: "Code", updates: {"parameters.jsCode": {"__patch_find_replace": [{"find": "const limit = 10;", "replace": "const limit = 50;"}]}}}]})', '// Surgically edit code using __patch_find_replace (avoids replacing entire code block)\nn8n_update_partial_workflow({id: "pfr1", operations: [{type: "updateNode", nodeName: "Code", updates: {"parameters.jsCode": {"__patch_find_replace": [{"find": "const limit = 10;", "replace": "const limit = 50;"}]}}}]})',
'// Multiple sequential patches on the same property\nn8n_update_partial_workflow({id: "pfr2", operations: [{type: "updateNode", nodeName: "Code", updates: {"parameters.jsCode": {"__patch_find_replace": [{"find": "api.old-domain.com", "replace": "api.new-domain.com"}, {"find": "Authorization: Bearer old_token", "replace": "Authorization: Bearer new_token"}]}}}]})', '// Multiple sequential patches on the same property\nn8n_update_partial_workflow({id: "pfr2", operations: [{type: "updateNode", nodeName: "Code", updates: {"parameters.jsCode": {"__patch_find_replace": [{"find": "api.old-domain.com", "replace": "api.new-domain.com"}, {"find": "Authorization: Bearer old_token", "replace": "Authorization: Bearer new_token"}]}}}]})',
'\n// ============ PATCHNODEFIELD EXAMPLES (strict find/replace) ============',
'// Surgical code edit with patchNodeField (errors if not found)\nn8n_update_partial_workflow({id: "pnf1", operations: [{type: "patchNodeField", nodeName: "Code", fieldPath: "parameters.jsCode", patches: [{find: "const limit = 10;", replace: "const limit = 50;"}]}]})',
'// Replace all occurrences of a string\nn8n_update_partial_workflow({id: "pnf2", operations: [{type: "patchNodeField", nodeName: "Code", fieldPath: "parameters.jsCode", patches: [{find: "api.old.com", replace: "api.new.com", replaceAll: true}]}]})',
'// Multiple sequential patches\nn8n_update_partial_workflow({id: "pnf3", operations: [{type: "patchNodeField", nodeName: "Set Email", fieldPath: "parameters.assignments.assignments.6.value", patches: [{find: "© 2025 n8n-mcp", replace: "© 2026 n8n-mcp"}, {find: "<p>Unsubscribe</p>", replace: ""}]}]})',
'// Regex-based replacement\nn8n_update_partial_workflow({id: "pnf4", operations: [{type: "patchNodeField", nodeName: "Code", fieldPath: "parameters.jsCode", patches: [{find: "const\\\\s+limit\\\\s*=\\\\s*\\\\d+", replace: "const limit = 100", regex: true}]}]})',
'\n// ============ AI CONNECTION EXAMPLES ============', '\n// ============ AI CONNECTION EXAMPLES ============',
'// Connect language model to AI Agent\nn8n_update_partial_workflow({id: "ai1", operations: [{type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel"}]})', '// Connect language model to AI Agent\nn8n_update_partial_workflow({id: "ai1", operations: [{type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel"}]})',
'// Connect tool to AI Agent\nn8n_update_partial_workflow({id: "ai2", operations: [{type: "addConnection", source: "HTTP Request Tool", target: "AI Agent", sourceOutput: "ai_tool"}]})', '// Connect tool to AI Agent\nn8n_update_partial_workflow({id: "ai2", operations: [{type: "addConnection", source: "HTTP Request Tool", target: "AI Agent", sourceOutput: "ai_tool"}]})',
@@ -373,7 +379,10 @@ n8n_update_partial_workflow({
'Configure Vector Store retrieval systems', 'Configure Vector Store retrieval systems',
'Swap language models in existing AI workflows', 'Swap language models in existing AI workflows',
'Batch-update AI tool connections', 'Batch-update AI tool connections',
'Transfer workflows between team projects (enterprise)' 'Transfer workflows between team projects (enterprise)',
'Surgical string edits in email templates, code, or JSON bodies (patchNodeField)',
'Fix typos or update URLs in large HTML content without re-transmitting the full string',
'Bulk find/replace across node field content (replaceAll flag)'
], ],
performance: 'Very fast - typically 50-200ms. Much faster than full updates as only changes are processed.', performance: 'Very fast - typically 50-200ms. Much faster than full updates as only changes are processed.',
bestPractices: [ bestPractices: [
@@ -396,7 +405,10 @@ n8n_update_partial_workflow({
'To remove properties, set them to null in the updates object', 'To remove properties, set them to null in the updates object',
'When migrating from deprecated properties, remove the old property and add the new one in the same operation', 'When migrating from deprecated properties, remove the old property and add the new one in the same operation',
'Use null to resolve mutual exclusivity validation errors between properties', 'Use null to resolve mutual exclusivity validation errors between properties',
'Batch multiple property removals in a single updateNode operation for efficiency' 'Batch multiple property removals in a single updateNode operation for efficiency',
'Prefer patchNodeField over __patch_find_replace for strict error handling — patchNodeField errors on not-found and detects ambiguous matches',
'Use replaceAll: true in patchNodeField when you want to replace all occurrences of a string',
'Use regex: true in patchNodeField for pattern-based replacements (e.g., whitespace-insensitive matching)'
], ],
pitfalls: [ pitfalls: [
'**REQUIRES N8N_API_URL and N8N_API_KEY environment variables** - will not work without n8n API access', '**REQUIRES N8N_API_URL and N8N_API_KEY environment variables** - will not work without n8n API access',
@@ -419,6 +431,9 @@ n8n_update_partial_workflow({
'**Corrupted workflows beyond repair**: Workflows in paradoxical states (API returns corrupt, API rejects updates) cannot be fixed via API - must be recreated', '**Corrupted workflows beyond repair**: Workflows in paradoxical states (API returns corrupt, API rejects updates) cannot be fixed via API - must be recreated',
'**__patch_find_replace for code edits**: Instead of replacing entire code blocks, use `{"parameters.jsCode": {"__patch_find_replace": [{"find": "old text", "replace": "new text"}]}}` to surgically edit string properties', '**__patch_find_replace for code edits**: Instead of replacing entire code blocks, use `{"parameters.jsCode": {"__patch_find_replace": [{"find": "old text", "replace": "new text"}]}}` to surgically edit string properties',
'__patch_find_replace replaces the FIRST occurrence of each find string. Patches are applied sequentially — order matters', '__patch_find_replace replaces the FIRST occurrence of each find string. Patches are applied sequentially — order matters',
'**patchNodeField is strict**: it ERRORS if the find string is not found (unlike __patch_find_replace which only warns)',
'**patchNodeField detects ambiguity**: if find matches multiple times, it ERRORS unless replaceAll: true is set',
'When using regex: true in patchNodeField, escape special regex characters (., *, +, etc.) if you want literal matching',
'To remove a property, set it to null in the updates object', 'To remove a property, set it to null in the updates object',
'When properties are mutually exclusive (e.g., continueOnFail and onError), setting only the new property will fail - you must remove the old one with null', 'When properties are mutually exclusive (e.g., continueOnFail and onError), setting only the new property will fail - you must remove the old one with null',
'Removing a required property may cause validation errors - check node documentation first', 'Removing a required property may cause validation errors - check node documentation first',

View File

@@ -147,7 +147,7 @@ export const n8nManagementTools: ToolDefinition[] = [
}, },
{ {
name: 'n8n_update_partial_workflow', name: 'n8n_update_partial_workflow',
description: `Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, moveNode, enable/disableNode, addConnection, removeConnection, updateSettings, updateName, add/removeTag, activate/deactivateWorkflow, transferWorkflow. See tools_documentation("n8n_update_partial_workflow", "full") for details.`, description: `Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, patchNodeField, moveNode, enable/disableNode, addConnection, removeConnection, updateSettings, updateName, add/removeTag, activate/deactivateWorkflow, transferWorkflow. See tools_documentation("n8n_update_partial_workflow", "full") for details.`,
inputSchema: { inputSchema: {
type: 'object', type: 'object',
additionalProperties: true, // Allow any extra properties Claude Desktop might add additionalProperties: true, // Allow any extra properties Claude Desktop might add

View File

@@ -29,7 +29,8 @@ import {
DeactivateWorkflowOperation, DeactivateWorkflowOperation,
CleanStaleConnectionsOperation, CleanStaleConnectionsOperation,
ReplaceConnectionsOperation, ReplaceConnectionsOperation,
TransferWorkflowOperation TransferWorkflowOperation,
PatchNodeFieldOperation
} from '../types/workflow-diff'; } from '../types/workflow-diff';
import { Workflow, WorkflowNode, WorkflowConnection } from '../types/n8n-api'; import { Workflow, WorkflowNode, WorkflowConnection } from '../types/n8n-api';
import { Logger } from '../utils/logger'; import { Logger } from '../utils/logger';
@@ -39,6 +40,55 @@ import { isActivatableTrigger } from '../utils/node-type-utils';
const logger = new Logger({ prefix: '[WorkflowDiffEngine]' }); const logger = new Logger({ prefix: '[WorkflowDiffEngine]' });
// Safety limits for patchNodeField operations
const PATCH_LIMITS = {
MAX_PATCHES: 50, // Max patches per operation
MAX_REGEX_LENGTH: 500, // Max regex pattern length (chars)
MAX_FIELD_SIZE_REGEX: 512 * 1024, // Max field size for regex operations (512KB)
};
// Keys that must never appear in property paths (prototype pollution prevention)
const DANGEROUS_PATH_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
/**
* Check if a regex pattern contains constructs known to cause catastrophic backtracking.
* Detects nested quantifiers like (a+)+, (a*)+, (a+)*, (a|b+)+ etc.
*/
function isUnsafeRegex(pattern: string): boolean {
// Detect nested quantifiers: a quantifier applied to a group that itself contains a quantifier
// Examples: (a+)+, (a+)*, (.*)+, (\w+)*, (a|b+)+
// This catches the most common ReDoS patterns
const nestedQuantifier = /\([^)]*[+*][^)]*\)[+*{]/;
if (nestedQuantifier.test(pattern)) return true;
// Detect overlapping alternations with quantifiers: (a|a)+, (\w|\d)+
const overlappingAlternation = /\([^)]*\|[^)]*\)[+*{]/;
// Only flag if alternation branches share characters (heuristic: both contain \w, ., or same literal)
if (overlappingAlternation.test(pattern)) {
const match = pattern.match(/\(([^)]*)\|([^)]*)\)[+*{]/);
if (match) {
const [, left, right] = match;
// Flag if both branches use broad character classes
const broadClasses = ['.', '\\w', '\\d', '\\s', '\\S', '\\W', '\\D', '[^'];
const leftHasBroad = broadClasses.some(c => left.includes(c));
const rightHasBroad = broadClasses.some(c => right.includes(c));
if (leftHasBroad && rightHasBroad) return true;
}
}
return false;
}
function countOccurrences(str: string, search: string): number {
let count = 0;
let pos = 0;
while ((pos = str.indexOf(search, pos)) !== -1) {
count++;
pos += search.length;
}
return count;
}
/** /**
* Not safe for concurrent use — create a new instance per request. * Not safe for concurrent use — create a new instance per request.
* Instance state is reset at the start of each applyDiff() call. * Instance state is reset at the start of each applyDiff() call.
@@ -79,7 +129,7 @@ export class WorkflowDiffEngine {
const workflowCopy = JSON.parse(JSON.stringify(workflow)); const workflowCopy = JSON.parse(JSON.stringify(workflow));
// Group operations by type for two-pass processing // Group operations by type for two-pass processing
const nodeOperationTypes = ['addNode', 'removeNode', 'updateNode', 'moveNode', 'enableNode', 'disableNode']; const nodeOperationTypes = ['addNode', 'removeNode', 'updateNode', 'patchNodeField', 'moveNode', 'enableNode', 'disableNode'];
const nodeOperations: Array<{ operation: WorkflowDiffOperation; index: number }> = []; const nodeOperations: Array<{ operation: WorkflowDiffOperation; index: number }> = [];
const otherOperations: Array<{ operation: WorkflowDiffOperation; index: number }> = []; const otherOperations: Array<{ operation: WorkflowDiffOperation; index: number }> = [];
@@ -296,6 +346,8 @@ export class WorkflowDiffEngine {
return this.validateRemoveNode(workflow, operation); return this.validateRemoveNode(workflow, operation);
case 'updateNode': case 'updateNode':
return this.validateUpdateNode(workflow, operation); return this.validateUpdateNode(workflow, operation);
case 'patchNodeField':
return this.validatePatchNodeField(workflow, operation as PatchNodeFieldOperation);
case 'moveNode': case 'moveNode':
return this.validateMoveNode(workflow, operation); return this.validateMoveNode(workflow, operation);
case 'enableNode': case 'enableNode':
@@ -341,6 +393,9 @@ export class WorkflowDiffEngine {
case 'updateNode': case 'updateNode':
this.applyUpdateNode(workflow, operation); this.applyUpdateNode(workflow, operation);
break; break;
case 'patchNodeField':
this.applyPatchNodeField(workflow, operation as PatchNodeFieldOperation);
break;
case 'moveNode': case 'moveNode':
this.applyMoveNode(workflow, operation); this.applyMoveNode(workflow, operation);
break; break;
@@ -498,6 +553,77 @@ export class WorkflowDiffEngine {
return null; return null;
} }
private validatePatchNodeField(workflow: Workflow, operation: PatchNodeFieldOperation): string | null {
if (!operation.nodeId && !operation.nodeName) {
return `patchNodeField requires either "nodeId" or "nodeName"`;
}
if (!operation.fieldPath || typeof operation.fieldPath !== 'string') {
return `patchNodeField requires a "fieldPath" string (e.g., "parameters.jsCode")`;
}
// Prototype pollution protection
const pathSegments = operation.fieldPath.split('.');
if (pathSegments.some(k => DANGEROUS_PATH_KEYS.has(k))) {
return `patchNodeField: fieldPath "${operation.fieldPath}" contains a forbidden key (__proto__, constructor, or prototype)`;
}
if (!Array.isArray(operation.patches) || operation.patches.length === 0) {
return `patchNodeField requires a non-empty "patches" array of {find, replace} objects`;
}
// Resource limit: max patches per operation
if (operation.patches.length > PATCH_LIMITS.MAX_PATCHES) {
return `patchNodeField: too many patches (${operation.patches.length}). Maximum is ${PATCH_LIMITS.MAX_PATCHES} per operation. Split into multiple operations if needed.`;
}
for (let i = 0; i < operation.patches.length; i++) {
const patch = operation.patches[i];
if (!patch || typeof patch.find !== 'string' || typeof patch.replace !== 'string') {
return `Invalid patch entry at index ${i}: each entry must have "find" (string) and "replace" (string)`;
}
if (patch.find.length === 0) {
return `Invalid patch entry at index ${i}: "find" must not be empty`;
}
if (patch.regex) {
// Resource limit: max regex pattern length
if (patch.find.length > PATCH_LIMITS.MAX_REGEX_LENGTH) {
return `Regex pattern at patch index ${i} is too long (${patch.find.length} chars). Maximum is ${PATCH_LIMITS.MAX_REGEX_LENGTH} characters.`;
}
try {
new RegExp(patch.find);
} catch (e) {
return `Invalid regex pattern at patch index ${i}: ${e instanceof Error ? e.message : 'invalid regex'}`;
}
// ReDoS protection: reject patterns with nested quantifiers
if (isUnsafeRegex(patch.find)) {
return `Potentially unsafe regex pattern at patch index ${i}: nested quantifiers or overlapping alternations can cause excessive backtracking. Simplify the pattern or use literal matching (regex: false).`;
}
}
}
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
if (!node) {
return this.formatNodeNotFoundError(workflow, operation.nodeId || operation.nodeName || '', 'patchNodeField');
}
const currentValue = this.getNestedProperty(node, operation.fieldPath);
if (currentValue === undefined) {
return `Cannot apply patchNodeField to "${operation.fieldPath}": property does not exist on node "${node.name}"`;
}
if (typeof currentValue !== 'string') {
return `Cannot apply patchNodeField to "${operation.fieldPath}": current value is ${typeof currentValue}, expected string`;
}
// Resource limit: cap field size for regex operations
const hasRegex = operation.patches.some(p => p.regex);
if (hasRegex && typeof currentValue === 'string' && currentValue.length > PATCH_LIMITS.MAX_FIELD_SIZE_REGEX) {
return `Field "${operation.fieldPath}" is too large for regex operations (${Math.round(currentValue.length / 1024)}KB). Maximum is ${PATCH_LIMITS.MAX_FIELD_SIZE_REGEX / 1024}KB. Use literal matching (regex: false) for large fields.`;
}
return null;
}
private validateMoveNode(workflow: Workflow, operation: MoveNodeOperation): string | null { private validateMoveNode(workflow: Workflow, operation: MoveNodeOperation): string | null {
const node = this.findNode(workflow, operation.nodeId, operation.nodeName); const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
if (!node) { if (!node) {
@@ -775,10 +901,74 @@ export class WorkflowDiffEngine {
Object.assign(node, sanitized); Object.assign(node, sanitized);
} }
private applyPatchNodeField(workflow: Workflow, operation: PatchNodeFieldOperation): void {
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
if (!node) return;
this.modifiedNodeIds.add(node.id);
let current = this.getNestedProperty(node, operation.fieldPath) as string;
for (let i = 0; i < operation.patches.length; i++) {
const patch = operation.patches[i];
if (patch.regex) {
const globalRegex = new RegExp(patch.find, 'g');
const matches = current.match(globalRegex);
if (!matches || matches.length === 0) {
throw new Error(
`patchNodeField: regex pattern "${patch.find}" not found in "${operation.fieldPath}" (patch index ${i}). ` +
`Use n8n_get_workflow to inspect the current value.`
);
}
if (matches.length > 1 && !patch.replaceAll) {
throw new Error(
`patchNodeField: regex pattern "${patch.find}" matches ${matches.length} times in "${operation.fieldPath}" (patch index ${i}). ` +
`Set "replaceAll": true to replace all occurrences, or refine the pattern to match exactly once.`
);
}
const regex = patch.replaceAll ? globalRegex : new RegExp(patch.find);
current = current.replace(regex, patch.replace);
} else {
const occurrences = countOccurrences(current, patch.find);
if (occurrences === 0) {
throw new Error(
`patchNodeField: "${patch.find.substring(0, 80)}" not found in "${operation.fieldPath}" (patch index ${i}). ` +
`Ensure the find string exactly matches the current content (including whitespace and newlines). ` +
`Use n8n_get_workflow to inspect the current value.`
);
}
if (occurrences > 1 && !patch.replaceAll) {
throw new Error(
`patchNodeField: "${patch.find.substring(0, 80)}" found ${occurrences} times in "${operation.fieldPath}" (patch index ${i}). ` +
`Set "replaceAll": true to replace all occurrences, or use a more specific find string that matches exactly once.`
);
}
if (patch.replaceAll) {
current = current.split(patch.find).join(patch.replace);
} else {
current = current.replace(patch.find, patch.replace);
}
}
}
this.setNestedProperty(node, operation.fieldPath, current);
// Sanitize node after updates
const sanitized = sanitizeNode(node);
Object.assign(node, sanitized);
}
private applyMoveNode(workflow: Workflow, operation: MoveNodeOperation): void { private applyMoveNode(workflow: Workflow, operation: MoveNodeOperation): void {
const node = this.findNode(workflow, operation.nodeId, operation.nodeName); const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
if (!node) return; if (!node) return;
node.position = operation.position; node.position = operation.position;
} }
@@ -1320,6 +1510,7 @@ export class WorkflowDiffEngine {
const keys = path.split('.'); const keys = path.split('.');
let current = obj; let current = obj;
for (const key of keys) { for (const key of keys) {
if (DANGEROUS_PATH_KEYS.has(key)) return undefined;
if (current == null || typeof current !== 'object') return undefined; if (current == null || typeof current !== 'object') return undefined;
current = current[key]; current = current[key];
} }
@@ -1330,6 +1521,11 @@ export class WorkflowDiffEngine {
const keys = path.split('.'); const keys = path.split('.');
let current = obj; let current = obj;
// Prototype pollution protection
if (keys.some(k => DANGEROUS_PATH_KEYS.has(k))) {
throw new Error(`Invalid property path: "${path}" contains a forbidden key`);
}
for (let i = 0; i < keys.length - 1; i++) { for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]; const key = keys[i];
if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) { if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) {

View File

@@ -55,6 +55,19 @@ export interface DisableNodeOperation extends DiffOperation {
nodeName?: string; nodeName?: string;
} }
export interface PatchNodeFieldOperation extends DiffOperation {
type: 'patchNodeField';
nodeId?: string;
nodeName?: string;
fieldPath: string; // Dot-notation path, e.g. "parameters.jsCode"
patches: Array<{
find: string;
replace: string;
replaceAll?: boolean; // Default: false. Replace all occurrences.
regex?: boolean; // Default: false. Treat find as a regex pattern.
}>;
}
// Connection Operations // Connection Operations
export interface AddConnectionOperation extends DiffOperation { export interface AddConnectionOperation extends DiffOperation {
type: 'addConnection'; type: 'addConnection';
@@ -153,6 +166,7 @@ export type WorkflowDiffOperation =
| AddNodeOperation | AddNodeOperation
| RemoveNodeOperation | RemoveNodeOperation
| UpdateNodeOperation | UpdateNodeOperation
| PatchNodeFieldOperation
| MoveNodeOperation | MoveNodeOperation
| EnableNodeOperation | EnableNodeOperation
| DisableNodeOperation | DisableNodeOperation
@@ -208,10 +222,10 @@ export interface NodeReference {
} }
// Utility functions type guards // Utility functions type guards
export function isNodeOperation(op: WorkflowDiffOperation): op is export function isNodeOperation(op: WorkflowDiffOperation): op is
AddNodeOperation | RemoveNodeOperation | UpdateNodeOperation | AddNodeOperation | RemoveNodeOperation | UpdateNodeOperation | PatchNodeFieldOperation |
MoveNodeOperation | EnableNodeOperation | DisableNodeOperation { MoveNodeOperation | EnableNodeOperation | DisableNodeOperation {
return ['addNode', 'removeNode', 'updateNode', 'moveNode', 'enableNode', 'disableNode'].includes(op.type); return ['addNode', 'removeNode', 'updateNode', 'patchNodeField', 'moveNode', 'enableNode', 'disableNode'].includes(op.type);
} }
export function isConnectionOperation(op: WorkflowDiffOperation): op is export function isConnectionOperation(op: WorkflowDiffOperation): op is

View File

@@ -219,9 +219,23 @@ describe('HTTP Server n8n Mode', () => {
mcp: { mcp: {
method: 'POST', method: 'POST',
path: '/mcp', path: '/mcp',
description: 'Main MCP JSON-RPC endpoint', description: 'Main MCP JSON-RPC endpoint (StreamableHTTP)',
authentication: 'Bearer token required' authentication: 'Bearer token required'
}, },
sse: {
method: 'GET',
path: '/sse',
description: 'DEPRECATED: SSE stream for legacy clients. Migrate to StreamableHTTP (POST /mcp).',
authentication: 'Bearer token required',
deprecated: true
},
messages: {
method: 'POST',
path: '/messages',
description: 'DEPRECATED: Message delivery for SSE sessions. Migrate to StreamableHTTP (POST /mcp).',
authentication: 'Bearer token required',
deprecated: true
},
health: { health: {
method: 'GET', method: 'GET',
path: '/health', path: '/health',

View File

@@ -59,11 +59,24 @@ vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
}) })
})); }));
vi.mock('@modelcontextprotocol/sdk/server/sse.js', () => ({ vi.mock('@modelcontextprotocol/sdk/server/sse.js', () => {
SSEServerTransport: vi.fn().mockImplementation(() => ({ class MockSSEServerTransport {
close: vi.fn().mockResolvedValue(undefined) sessionId: string;
})) onclose: (() => void) | null = null;
})); onerror: ((error: Error) => void) | null = null;
close = vi.fn().mockResolvedValue(undefined);
handlePostMessage = vi.fn().mockImplementation(async (_req: any, res: any) => {
res.writeHead(202);
res.end('Accepted');
});
start = vi.fn().mockResolvedValue(undefined);
constructor(_endpoint: string, _res: any) {
this.sessionId = 'sse-' + Math.random().toString(36).substring(2, 11);
}
}
return { SSEServerTransport: MockSSEServerTransport };
});
vi.mock('../../src/mcp/server', () => ({ vi.mock('../../src/mcp/server', () => ({
N8NDocumentationMCPServer: vi.fn().mockImplementation(() => ({ N8NDocumentationMCPServer: vi.fn().mockImplementation(() => ({
@@ -1100,24 +1113,16 @@ describe('HTTP Server Session Management', () => {
'session-2': { lastAccess: new Date(), createdAt: new Date() } 'session-2': { lastAccess: new Date(), createdAt: new Date() }
}; };
// Set up legacy session for SSE compatibility
const mockLegacyTransport = { close: vi.fn().mockResolvedValue(undefined) };
(server as any).session = {
transport: mockLegacyTransport
};
await server.shutdown(); await server.shutdown();
// All transports should be closed // All transports should be closed
expect(mockTransport1.close).toHaveBeenCalled(); expect(mockTransport1.close).toHaveBeenCalled();
expect(mockTransport2.close).toHaveBeenCalled(); expect(mockTransport2.close).toHaveBeenCalled();
expect(mockLegacyTransport.close).toHaveBeenCalled();
// All data structures should be cleared // All data structures should be cleared
expect(Object.keys((server as any).transports)).toHaveLength(0); expect(Object.keys((server as any).transports)).toHaveLength(0);
expect(Object.keys((server as any).servers)).toHaveLength(0); expect(Object.keys((server as any).servers)).toHaveLength(0);
expect(Object.keys((server as any).sessionMetadata)).toHaveLength(0); expect(Object.keys((server as any).sessionMetadata)).toHaveLength(0);
expect((server as any).session).toBe(null);
}); });
it('should handle transport close errors during shutdown', async () => { it('should handle transport close errors during shutdown', async () => {
@@ -1169,22 +1174,21 @@ describe('HTTP Server Session Management', () => {
expect(Array.isArray(sessionInfo.sessions!.sessionIds)).toBe(true); expect(Array.isArray(sessionInfo.sessions!.sessionIds)).toBe(true);
}); });
it('should show legacy SSE session when present', async () => { it('should show active when transports exist', async () => {
server = new SingleSessionHTTPServer(); server = new SingleSessionHTTPServer();
// Mock legacy session // Add a transport to simulate an active session
const mockSession = { (server as any).transports['session-123'] = { close: vi.fn() };
sessionId: 'sse-session-123', (server as any).sessionMetadata['session-123'] = {
lastAccess: new Date(), lastAccess: new Date(),
isSSE: true createdAt: new Date()
}; };
(server as any).session = mockSession;
const sessionInfo = server.getSessionInfo(); const sessionInfo = server.getSessionInfo();
expect(sessionInfo.active).toBe(true); expect(sessionInfo.active).toBe(true);
expect(sessionInfo.sessionId).toBe('sse-session-123'); expect(sessionInfo.sessions!.total).toBe(1);
expect(sessionInfo.age).toBeGreaterThanOrEqual(0); expect(sessionInfo.sessions!.sessionIds).toContain('session-123');
}); });
}); });

View File

@@ -428,6 +428,22 @@ describe('WorkflowDiffEngine', () => {
expect(result.errors![0].message).toContain('Correct structure:'); expect(result.errors![0].message).toContain('Correct structure:');
}); });
it('should reject prototype pollution via update path', async () => {
const result = await diffEngine.applyDiff(baseWorkflow, {
id: 'test',
operations: [{
type: 'updateNode' as const,
nodeId: 'http-1',
updates: {
'__proto__.polluted': 'malicious'
}
}]
});
expect(result.success).toBe(false);
expect(result.errors?.[0]?.message).toContain('forbidden key');
});
it('should apply __patch_find_replace to string properties (#642)', async () => { it('should apply __patch_find_replace to string properties (#642)', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow)); const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({ workflow.nodes.push({
@@ -581,6 +597,520 @@ describe('WorkflowDiffEngine', () => {
}); });
}); });
describe('PatchNodeField Operation', () => {
it('should apply single find/replace patch', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { jsCode: 'const x = 1;\nreturn x + 2;' }
});
const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
patches: [{ find: 'x + 2', replace: 'x + 3' }]
}]
});
expect(result.success).toBe(true);
const codeNode = result.workflow.nodes.find((n: any) => n.name === 'Code');
expect(codeNode?.parameters.jsCode).toBe('const x = 1;\nreturn x + 3;');
});
it('should error when find string not found', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { jsCode: 'const x = 1;' }
});
const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
patches: [{ find: 'nonexistent text', replace: 'something' }]
}]
});
expect(result.success).toBe(false);
expect(result.errors?.[0]?.message).toContain('not found');
});
it('should error on ambiguous match (multiple occurrences)', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { jsCode: 'const a = 1;\nconst b = 1;\nconst c = 1;' }
});
const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
patches: [{ find: 'const', replace: 'let' }]
}]
});
expect(result.success).toBe(false);
expect(result.errors?.[0]?.message).toContain('3 times');
expect(result.errors?.[0]?.message).toContain('replaceAll');
});
it('should replace all occurrences with replaceAll flag', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { jsCode: 'const a = 1;\nconst b = 2;\nconst c = 3;' }
});
const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
patches: [{ find: 'const', replace: 'let', replaceAll: true }]
}]
});
expect(result.success).toBe(true);
const codeNode = result.workflow.nodes.find((n: any) => n.name === 'Code');
expect(codeNode?.parameters.jsCode).toBe('let a = 1;\nlet b = 2;\nlet c = 3;');
});
it('should apply multiple sequential patches', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { jsCode: 'const a = 1;\nconst b = 2;\nreturn a + b;' }
});
const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
patches: [
{ find: 'const a = 1', replace: 'const a = 10' },
{ find: 'const b = 2', replace: 'const b = 20' }
]
}]
});
expect(result.success).toBe(true);
const codeNode = result.workflow.nodes.find((n: any) => n.name === 'Code');
expect(codeNode?.parameters.jsCode).toBe('const a = 10;\nconst b = 20;\nreturn a + b;');
});
it('should support regex pattern matching', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { jsCode: 'const limit = 42;' }
});
const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
patches: [{ find: 'const limit = \\d+', replace: 'const limit = 100', regex: true }]
}]
});
expect(result.success).toBe(true);
const codeNode = result.workflow.nodes.find((n: any) => n.name === 'Code');
expect(codeNode?.parameters.jsCode).toBe('const limit = 100;');
});
it('should support regex with replaceAll', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { jsCode: 'item1 = 10;\nitem2 = 20;\nitem3 = 30;' }
});
const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
patches: [{ find: 'item\\d+', replace: 'val', regex: true, replaceAll: true }]
}]
});
expect(result.success).toBe(true);
const codeNode = result.workflow.nodes.find((n: any) => n.name === 'Code');
expect(codeNode?.parameters.jsCode).toBe('val = 10;\nval = 20;\nval = 30;');
});
it('should error on ambiguous regex match without replaceAll', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { jsCode: 'item1 = 10;\nitem2 = 20;' }
});
const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
patches: [{ find: 'item\\d+', replace: 'val', regex: true }]
}]
});
expect(result.success).toBe(false);
expect(result.errors?.[0]?.message).toContain('2 times');
});
it('should reject invalid regex pattern in validation', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { jsCode: 'const x = 1;' }
});
const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
patches: [{ find: '(unclosed', replace: 'x', regex: true }]
}]
});
expect(result.success).toBe(false);
expect(result.errors?.[0]?.message).toContain('Invalid regex');
});
it('should error on non-existent field', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { jsCode: 'const x = 1;' }
});
const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.nonExistent',
patches: [{ find: 'x', replace: 'y' }]
}]
});
expect(result.success).toBe(false);
expect(result.errors?.[0]?.message).toContain('does not exist');
});
it('should error on non-string field', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { retryCount: 3 }
});
const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.retryCount',
patches: [{ find: '3', replace: '5' }]
}]
});
expect(result.success).toBe(false);
expect(result.errors?.[0]?.message).toContain('expected string');
});
it('should error on missing node', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'NonExistent',
fieldPath: 'parameters.jsCode',
patches: [{ find: 'x', replace: 'y' }]
}]
});
expect(result.success).toBe(false);
expect(result.errors?.[0]?.message).toContain('not found');
});
it('should reject empty patches array', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { jsCode: 'const x = 1;' }
});
const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
patches: []
}]
});
expect(result.success).toBe(false);
expect(result.errors?.[0]?.message).toContain('non-empty');
});
it('should reject empty find string', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { jsCode: 'const x = 1;' }
});
const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
patches: [{ find: '', replace: 'y' }]
}]
});
expect(result.success).toBe(false);
expect(result.errors?.[0]?.message).toContain('must not be empty');
});
it('should work with nested fieldPath using dot notation', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'set-1',
name: 'Set',
type: 'n8n-nodes-base.set',
typeVersion: 3,
position: [900, 300],
parameters: {
options: {
template: '<p>Hello World</p>'
}
}
});
const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Set',
fieldPath: 'parameters.options.template',
patches: [{ find: 'Hello World', replace: 'Goodbye World' }]
}]
});
expect(result.success).toBe(true);
const setNode = result.workflow.nodes.find((n: any) => n.name === 'Set');
expect(setNode?.parameters.options.template).toBe('<p>Goodbye World</p>');
});
it('should reject prototype pollution via fieldPath', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { jsCode: 'const x = 1;' }
});
const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: '__proto__.polluted',
patches: [{ find: 'x', replace: 'y' }]
}]
});
expect(result.success).toBe(false);
expect(result.errors?.[0]?.message).toContain('forbidden key');
});
it('should reject unsafe regex patterns (ReDoS)', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { jsCode: 'const x = 1;' }
});
const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
patches: [{ find: '(a+)+$', replace: 'safe', regex: true }]
}]
});
expect(result.success).toBe(false);
expect(result.errors?.[0]?.message).toContain('unsafe regex');
});
it('should reject too many patches', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { jsCode: 'const x = 1;' }
});
const patches = Array.from({ length: 51 }, (_, i) => ({
find: `pattern${i}`,
replace: `replacement${i}`
}));
const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
patches
}]
});
expect(result.success).toBe(false);
expect(result.errors?.[0]?.message).toContain('too many patches');
});
it('should reject overly long regex patterns', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { jsCode: 'const x = 1;' }
});
const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
patches: [{ find: 'a'.repeat(501), replace: 'b', regex: true }]
}]
});
expect(result.success).toBe(false);
expect(result.errors?.[0]?.message).toContain('too long');
});
it('should work with nodeId reference', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { jsCode: 'const x = 1;' }
});
const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeId: 'code-1',
fieldPath: 'parameters.jsCode',
patches: [{ find: 'const x = 1', replace: 'const x = 2' }]
}]
});
expect(result.success).toBe(true);
const codeNode = result.workflow.nodes.find((n: any) => n.id === 'code-1');
expect(codeNode?.parameters.jsCode).toBe('const x = 2;');
});
});
describe('MoveNode Operation', () => { describe('MoveNode Operation', () => {
it('should move node to new position', async () => { it('should move node to new position', async () => {
const operation: MoveNodeOperation = { const operation: MoveNodeOperation = {