mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-04-03 16:13:08 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12d7d5bdb6 | ||
|
|
2d4115530c |
44
CHANGELOG.md
44
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
5
dist/http-server-single-session.d.ts
vendored
5
dist/http-server-single-session.d.ts
vendored
@@ -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>;
|
||||||
|
|||||||
2
dist/http-server-single-session.d.ts.map
vendored
2
dist/http-server-single-session.d.ts.map
vendored
@@ -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"}
|
||||||
363
dist/http-server-single-session.js
vendored
363
dist/http-server-single-session.js
vendored
@@ -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,
|
||||||
|
|||||||
2
dist/http-server-single-session.js.map
vendored
2
dist/http-server-single-session.js.map
vendored
File diff suppressed because one or more lines are too long
2
dist/mcp/handlers-n8n-manager.d.ts.map
vendored
2
dist/mcp/handlers-n8n-manager.d.ts.map
vendored
@@ -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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"}
|
||||||
18
dist/mcp/handlers-n8n-manager.js
vendored
18
dist/mcp/handlers-n8n-manager.js
vendored
@@ -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);
|
||||||
|
|||||||
2
dist/mcp/handlers-n8n-manager.js.map
vendored
2
dist/mcp/handlers-n8n-manager.js.map
vendored
File diff suppressed because one or more lines are too long
2
dist/mcp/handlers-workflow-diff.d.ts.map
vendored
2
dist/mcp/handlers-workflow-diff.d.ts.map
vendored
@@ -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"}
|
||||||
10
dist/mcp/handlers-workflow-diff.js
vendored
10
dist/mcp/handlers-workflow-diff.js
vendored
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
2
dist/mcp/handlers-workflow-diff.js.map
vendored
2
dist/mcp/handlers-workflow-diff.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -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"}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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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"}
|
||||||
2
dist/mcp/tools-n8n-manager.js
vendored
2
dist/mcp/tools-n8n-manager.js
vendored
@@ -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,
|
||||||
|
|||||||
2
dist/mcp/tools-n8n-manager.js.map
vendored
2
dist/mcp/tools-n8n-manager.js.map
vendored
File diff suppressed because one or more lines are too long
2
dist/services/workflow-diff-engine.d.ts
vendored
2
dist/services/workflow-diff-engine.d.ts
vendored
@@ -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;
|
||||||
|
|||||||
2
dist/services/workflow-diff-engine.d.ts.map
vendored
2
dist/services/workflow-diff-engine.d.ts.map
vendored
@@ -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"}
|
||||||
147
dist/services/workflow-diff-engine.js
vendored
147
dist/services/workflow-diff-engine.js
vendored
@@ -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) {
|
||||||
|
|||||||
2
dist/services/workflow-diff-engine.js.map
vendored
2
dist/services/workflow-diff-engine.js.map
vendored
File diff suppressed because one or more lines are too long
16
dist/types/workflow-diff.d.ts
vendored
16
dist/types/workflow-diff.d.ts
vendored
@@ -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
|
||||||
2
dist/types/workflow-diff.d.ts.map
vendored
2
dist/types/workflow-diff.d.ts.map
vendored
@@ -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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"}
|
||||||
2
dist/types/workflow-diff.js
vendored
2
dist/types/workflow-diff.js
vendored
@@ -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);
|
||||||
|
|||||||
2
dist/types/workflow-diff.js.map
vendored
2
dist/types/workflow-diff.js.map
vendored
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user