mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-04-03 16:13:08 +00:00
fix: resolve SSE reconnection loop with separate /sse + /messages endpoints (v2.46.1) (#699)
Fix SSE clients entering rapid reconnection loops because POST /mcp never routed messages to SSEServerTransport.handlePostMessage() (#617). Root cause: SSE sessions were stored in a separate `this.session` property invisible to the StreamableHTTP POST handler. The POST handler only checked `this.transports` (StreamableHTTP map), so SSE messages were never delivered, causing immediate reconnection and rate limiter exhaustion. Changes: - Add GET /sse + POST /messages endpoints following the official MCP SDK backward-compatible server pattern (separate endpoints per transport) - Store SSE transports in the shared this.transports map with instanceof guards for type discrimination - Remove legacy this.session singleton, resetSessionSSE(), and isExpired() - Extract duplicated auth logic into authenticateRequest() method - Add Bearer token auth and rate limiting to SSE endpoints - Add skipSuccessfulRequests to authLimiter to prevent 429 storms - Mark SSE transport as deprecated (removed in MCP SDK v2.x) The handleRequest() codepath used by the downstream SaaS backend (N8NMCPEngine.processRequest()) is unchanged. Session persistence (exportSessionState/restoreSessionState) is unchanged. Closes #617 Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
2d4115530c
commit
12d7d5bdb6
25
CHANGELOG.md
25
CHANGELOG.md
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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
|
||||
|
||||
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 sessionContexts;
|
||||
private contextSwitchLocks;
|
||||
private session;
|
||||
private consoleManager;
|
||||
private expressServer;
|
||||
private sessionTimeout;
|
||||
@@ -29,14 +28,14 @@ export declare class SingleSessionHTTPServer {
|
||||
private isJsonRpcNotification;
|
||||
private sanitizeErrorForClient;
|
||||
private updateSessionAccess;
|
||||
private authenticateRequest;
|
||||
private switchSessionContext;
|
||||
private performContextSwitch;
|
||||
private getSessionMetrics;
|
||||
private loadAuthToken;
|
||||
private validateEnvironment;
|
||||
handleRequest(req: express.Request, res: express.Response, instanceContext?: InstanceContext): Promise<void>;
|
||||
private resetSessionSSE;
|
||||
private isExpired;
|
||||
private createSSESession;
|
||||
private isSessionExpired;
|
||||
start(): 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.sessionContexts = {};
|
||||
this.contextSwitchLocks = new Map();
|
||||
this.session = null;
|
||||
this.consoleManager = new console_manager_1.ConsoleManager();
|
||||
this.sessionTimeout = parseInt(process.env.SESSION_TIMEOUT_MINUTES || '30', 10) * 60 * 1000;
|
||||
this.authToken = null;
|
||||
@@ -170,6 +169,39 @@ class SingleSessionHTTPServer {
|
||||
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) {
|
||||
const existingLock = this.contextSwitchLocks.get(sessionId);
|
||||
if (existingLock) {
|
||||
@@ -392,6 +424,18 @@ class SingleSessionHTTPServer {
|
||||
return;
|
||||
}
|
||||
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];
|
||||
if (!transport) {
|
||||
if (this.isJsonRpcNotification(req.body)) {
|
||||
@@ -486,54 +530,33 @@ class SingleSessionHTTPServer {
|
||||
}
|
||||
});
|
||||
}
|
||||
async resetSessionSSE(res) {
|
||||
if (this.session) {
|
||||
const sessionId = this.session.sessionId;
|
||||
logger_1.logger.info('Closing previous session for SSE', { sessionId });
|
||||
if (this.session.server && typeof this.session.server.close === 'function') {
|
||||
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,
|
||||
async createSSESession(res) {
|
||||
if (!this.canCreateSession()) {
|
||||
logger_1.logger.warn('SSE session creation rejected: session limit reached', {
|
||||
currentSessions: this.getActiveSessionCount(),
|
||||
maxSessions: MAX_SESSIONS
|
||||
});
|
||||
const sessionId = (0, uuid_1.v4)();
|
||||
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 });
|
||||
throw new Error(`Session limit reached (${MAX_SESSIONS})`);
|
||||
}
|
||||
catch (error) {
|
||||
logger_1.logger.error('Failed to create SSE session:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
isExpired() {
|
||||
if (!this.session)
|
||||
return true;
|
||||
return Date.now() - this.session.lastAccess.getTime() > this.sessionTimeout;
|
||||
const server = new server_1.N8NDocumentationMCPServer(undefined, undefined, {
|
||||
generateWorkflowHandler: this.generateWorkflowHandler,
|
||||
});
|
||||
const transport = new sse_js_1.SSEServerTransport('/messages', res);
|
||||
const sessionId = transport.sessionId;
|
||||
this.transports[sessionId] = transport;
|
||||
this.servers[sessionId] = server;
|
||||
this.sessionMetadata[sessionId] = {
|
||||
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) {
|
||||
const metadata = this.sessionMetadata[sessionId];
|
||||
@@ -601,7 +624,7 @@ class SingleSessionHTTPServer {
|
||||
authentication: {
|
||||
type: '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'
|
||||
});
|
||||
@@ -633,7 +656,7 @@ class SingleSessionHTTPServer {
|
||||
},
|
||||
activeTransports: activeTransports.length,
|
||||
activeServers: activeServers.length,
|
||||
legacySessionActive: !!this.session,
|
||||
legacySessionActive: false,
|
||||
memory: {
|
||||
used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
|
||||
total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
|
||||
@@ -673,9 +696,10 @@ class SingleSessionHTTPServer {
|
||||
});
|
||||
app.get('/mcp', async (req, res) => {
|
||||
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 {
|
||||
await this.transports[sessionId].handleRequest(req, res, undefined);
|
||||
await existingTransport.handleRequest(req, res, undefined);
|
||||
return;
|
||||
}
|
||||
catch (error) {
|
||||
@@ -684,22 +708,12 @@ class SingleSessionHTTPServer {
|
||||
}
|
||||
const accept = req.headers.accept;
|
||||
if (accept && accept.includes('text/event-stream')) {
|
||||
logger_1.logger.info('SSE stream request received - establishing SSE connection');
|
||||
try {
|
||||
await this.resetSessionSSE(res);
|
||||
logger_1.logger.info('SSE connection established successfully');
|
||||
}
|
||||
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
|
||||
});
|
||||
}
|
||||
logger_1.logger.info('SSE request on /mcp redirected to /sse', { ip: req.ip });
|
||||
res.status(400).json({
|
||||
error: 'SSE transport uses /sse endpoint',
|
||||
message: 'Connect via GET /sse for SSE streaming. POST messages to /messages?sessionId=<id>.',
|
||||
documentation: 'https://github.com/czlonkowski/n8n-mcp'
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (process.env.N8N_MODE === 'true') {
|
||||
@@ -724,9 +738,23 @@ class SingleSessionHTTPServer {
|
||||
mcp: {
|
||||
method: 'POST',
|
||||
path: '/mcp',
|
||||
description: 'Main MCP JSON-RPC endpoint',
|
||||
description: 'Main MCP JSON-RPC endpoint (StreamableHTTP)',
|
||||
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: {
|
||||
method: 'GET',
|
||||
path: '/health',
|
||||
@@ -743,6 +771,92 @@ class SingleSessionHTTPServer {
|
||||
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) => {
|
||||
const mcpSessionId = req.headers['mcp-session-id'];
|
||||
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) => {
|
||||
logger_1.logger.info('POST /mcp request received - DETAILED DEBUG', {
|
||||
headers: req.headers,
|
||||
@@ -864,63 +949,10 @@ class SingleSessionHTTPServer {
|
||||
req.removeListener('close', closeHandler);
|
||||
});
|
||||
}
|
||||
const authHeader = req.headers.authorization;
|
||||
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
|
||||
});
|
||||
if (!this.authenticateRequest(req, res))
|
||||
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', {
|
||||
hasSession: !!this.session,
|
||||
sessionType: this.session?.isSSE ? 'SSE' : 'StreamableHTTP',
|
||||
sessionInitialized: this.session?.initialized
|
||||
activeSessions: this.getActiveSessionCount()
|
||||
});
|
||||
const instanceContext = (() => {
|
||||
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(`Health check: ${endpoints.health}`);
|
||||
console.log(`MCP endpoint: ${endpoints.mcp}`);
|
||||
console.log(`SSE endpoint: ${baseUrl}/sse (legacy clients)`);
|
||||
if (isProduction) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
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) {
|
||||
await new Promise((resolve) => {
|
||||
this.expressServer.close(() => {
|
||||
@@ -1090,22 +1113,8 @@ class SingleSessionHTTPServer {
|
||||
}
|
||||
getSessionInfo() {
|
||||
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 {
|
||||
active: true,
|
||||
sessionId: this.session.sessionId,
|
||||
age: Date.now() - this.session.lastAccess.getTime(),
|
||||
active: metrics.activeSessions > 0,
|
||||
sessions: {
|
||||
total: metrics.totalSessions,
|
||||
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
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.44.1",
|
||||
"version": "2.46.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.44.1",
|
||||
"version": "2.46.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.28.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.46.0",
|
||||
"version": "2.46.1",
|
||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -46,15 +46,6 @@ interface MultiTenantHeaders {
|
||||
const MAX_SESSIONS = Math.max(1, parseInt(process.env.N8N_MCP_MAX_SESSIONS || '100', 10));
|
||||
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 {
|
||||
totalSessions: number;
|
||||
activeSessions: number;
|
||||
@@ -104,12 +95,12 @@ export interface SingleSessionHTTPServerOptions {
|
||||
|
||||
export class SingleSessionHTTPServer {
|
||||
// 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 sessionMetadata: { [sessionId: string]: { lastAccess: Date; createdAt: Date } } = {};
|
||||
private sessionContexts: { [sessionId: string]: InstanceContext | undefined } = {};
|
||||
private contextSwitchLocks: Map<string, Promise<void>> = new Map();
|
||||
private session: Session | null = null; // Keep for SSE compatibility
|
||||
private consoleManager = new ConsoleManager();
|
||||
private expressServer: any;
|
||||
// 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
|
||||
*/
|
||||
@@ -636,7 +670,22 @@ export class SingleSessionHTTPServer {
|
||||
|
||||
// For non-initialize requests: reuse existing transport for this session
|
||||
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
|
||||
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> {
|
||||
// Clean up old session if exists
|
||||
if (this.session) {
|
||||
const sessionId = this.session.sessionId;
|
||||
logger.info('Closing previous session for SSE', { sessionId });
|
||||
|
||||
// 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,
|
||||
private async createSSESession(res: express.Response): Promise<void> {
|
||||
if (!this.canCreateSession()) {
|
||||
logger.warn('SSE session creation rejected: session limit reached', {
|
||||
currentSessions: this.getActiveSessionCount(),
|
||||
maxSessions: 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;
|
||||
throw new Error(`Session limit reached (${MAX_SESSIONS})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current session is expired
|
||||
*/
|
||||
private isExpired(): boolean {
|
||||
if (!this.session) return true;
|
||||
return Date.now() - this.session.lastAccess.getTime() > this.sessionTimeout;
|
||||
|
||||
// Note: SSE sessions do not support multi-tenant context.
|
||||
// The SaaS backend uses StreamableHTTP exclusively.
|
||||
const server = new N8NDocumentationMCPServer(undefined, undefined, {
|
||||
generateWorkflowHandler: this.generateWorkflowHandler,
|
||||
});
|
||||
|
||||
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: {
|
||||
type: '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'
|
||||
});
|
||||
@@ -948,7 +971,7 @@ export class SingleSessionHTTPServer {
|
||||
},
|
||||
activeTransports: activeTransports.length, // Legacy field
|
||||
activeServers: activeServers.length, // Legacy field
|
||||
legacySessionActive: !!this.session, // For SSE compatibility
|
||||
legacySessionActive: false, // Deprecated: SSE now uses shared transports map
|
||||
memory: {
|
||||
used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
|
||||
total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
|
||||
@@ -1005,10 +1028,11 @@ export class SingleSessionHTTPServer {
|
||||
app.get('/mcp', async (req, res) => {
|
||||
// Handle StreamableHTTP transport requests with new pattern
|
||||
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
|
||||
try {
|
||||
await this.transports[sessionId].handleRequest(req, res, undefined);
|
||||
await existingTransport.handleRequest(req, res, undefined);
|
||||
return;
|
||||
} catch (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;
|
||||
if (accept && accept.includes('text/event-stream')) {
|
||||
logger.info('SSE stream request received - establishing SSE connection');
|
||||
|
||||
try {
|
||||
// Create or reset session for SSE
|
||||
await this.resetSessionSSE(res);
|
||||
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
|
||||
});
|
||||
}
|
||||
logger.info('SSE request on /mcp redirected to /sse', { ip: req.ip });
|
||||
res.status(400).json({
|
||||
error: 'SSE transport uses /sse endpoint',
|
||||
message: 'Connect via GET /sse for SSE streaming. POST messages to /messages?sessionId=<id>.',
|
||||
documentation: 'https://github.com/czlonkowski/n8n-mcp'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1072,9 +1085,23 @@ export class SingleSessionHTTPServer {
|
||||
mcp: {
|
||||
method: 'POST',
|
||||
path: '/mcp',
|
||||
description: 'Main MCP JSON-RPC endpoint',
|
||||
description: 'Main MCP JSON-RPC endpoint (StreamableHTTP)',
|
||||
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: {
|
||||
method: 'GET',
|
||||
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
|
||||
app.delete('/mcp', async (req: express.Request, res: express.Response): Promise<void> => {
|
||||
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
|
||||
app.post('/mcp', authLimiter, jsonParser, async (req: express.Request, res: express.Response): Promise<void> => {
|
||||
// Log comprehensive debug info about the request
|
||||
@@ -1234,76 +1331,10 @@ export class SingleSessionHTTPServer {
|
||||
});
|
||||
}
|
||||
|
||||
// Enhanced authentication check with specific logging
|
||||
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();
|
||||
if (!this.authenticateRequest(req, res)) return;
|
||||
|
||||
// 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', {
|
||||
hasSession: !!this.session,
|
||||
sessionType: this.session?.isSSE ? 'SSE' : 'StreamableHTTP',
|
||||
sessionInitialized: this.session?.initialized
|
||||
activeSessions: this.getActiveSessionCount()
|
||||
});
|
||||
|
||||
// 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(`Health check: ${endpoints.health}`);
|
||||
console.log(`MCP endpoint: ${endpoints.mcp}`);
|
||||
console.log(`SSE endpoint: ${baseUrl}/sse (legacy clients)`);
|
||||
|
||||
if (isProduction) {
|
||||
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
|
||||
if (this.expressServer) {
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -1532,25 +1553,9 @@ export class SingleSessionHTTPServer {
|
||||
};
|
||||
} {
|
||||
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 {
|
||||
active: true,
|
||||
sessionId: this.session.sessionId,
|
||||
age: Date.now() - this.session.lastAccess.getTime(),
|
||||
active: metrics.activeSessions > 0,
|
||||
sessions: {
|
||||
total: metrics.totalSessions,
|
||||
active: metrics.activeSessions,
|
||||
|
||||
@@ -219,9 +219,23 @@ describe('HTTP Server n8n Mode', () => {
|
||||
mcp: {
|
||||
method: 'POST',
|
||||
path: '/mcp',
|
||||
description: 'Main MCP JSON-RPC endpoint',
|
||||
description: 'Main MCP JSON-RPC endpoint (StreamableHTTP)',
|
||||
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: {
|
||||
method: 'GET',
|
||||
path: '/health',
|
||||
|
||||
@@ -59,11 +59,24 @@ vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('@modelcontextprotocol/sdk/server/sse.js', () => ({
|
||||
SSEServerTransport: vi.fn().mockImplementation(() => ({
|
||||
close: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
}));
|
||||
vi.mock('@modelcontextprotocol/sdk/server/sse.js', () => {
|
||||
class MockSSEServerTransport {
|
||||
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', () => ({
|
||||
N8NDocumentationMCPServer: vi.fn().mockImplementation(() => ({
|
||||
@@ -1100,24 +1113,16 @@ describe('HTTP Server Session Management', () => {
|
||||
'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();
|
||||
|
||||
// All transports should be closed
|
||||
expect(mockTransport1.close).toHaveBeenCalled();
|
||||
expect(mockTransport2.close).toHaveBeenCalled();
|
||||
expect(mockLegacyTransport.close).toHaveBeenCalled();
|
||||
|
||||
// All data structures should be cleared
|
||||
expect(Object.keys((server as any).transports)).toHaveLength(0);
|
||||
expect(Object.keys((server as any).servers)).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 () => {
|
||||
@@ -1169,22 +1174,21 @@ describe('HTTP Server Session Management', () => {
|
||||
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();
|
||||
|
||||
// Mock legacy session
|
||||
const mockSession = {
|
||||
sessionId: 'sse-session-123',
|
||||
// Add a transport to simulate an active session
|
||||
(server as any).transports['session-123'] = { close: vi.fn() };
|
||||
(server as any).sessionMetadata['session-123'] = {
|
||||
lastAccess: new Date(),
|
||||
isSSE: true
|
||||
createdAt: new Date()
|
||||
};
|
||||
(server as any).session = mockSession;
|
||||
|
||||
const sessionInfo = server.getSessionInfo();
|
||||
|
||||
expect(sessionInfo.active).toBe(true);
|
||||
expect(sessionInfo.sessionId).toBe('sse-session-123');
|
||||
expect(sessionInfo.age).toBeGreaterThanOrEqual(0);
|
||||
expect(sessionInfo.sessions!.total).toBe(1);
|
||||
expect(sessionInfo.sessions!.sessionIds).toContain('session-123');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user