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:
Romuald Członkowski
2026-04-03 14:00:53 +02:00
committed by GitHub
parent 2d4115530c
commit 12d7d5bdb6
10 changed files with 493 additions and 437 deletions

View File

@@ -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

View File

@@ -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>;

View File

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

View File

@@ -51,7 +51,6 @@ class SingleSessionHTTPServer {
this.sessionMetadata = {};
this.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,

File diff suppressed because one or more lines are too long

4
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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,

View File

@@ -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',

View File

@@ -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');
});
});