From 58ca0a16ba2a05a11ba81153dab674f2844c5133 Mon Sep 17 00:00:00 2001 From: bitloi <89318445+bitloi@users.noreply.github.com> Date: Mon, 11 May 2026 08:55:11 -0300 Subject: [PATCH] :bug: Fix MCP SSE sessions leaking on zombie connections (#9432) (#9464) SSE sessions were never included in the periodic inactivity timeout checker, so a stale connection whose TCP close event never fired would retain its SSEServerTransport and McpServer indefinitely. Changes: - Add lastActiveTime: number to the sseTransports entry type - Initialise lastActiveTime at SSE session creation (GET /sse) - Refresh lastActiveTime on every incoming message (POST /messages) - Extend startSessionTimeoutChecker() to sweep and forcibly close SSE sessions idle for more than SESSION_TIMEOUT_MINUTES, mirroring the existing Streamable HTTP logic - Update the checker log to count both transport maps The existing res.on('close') cleanup path is preserved unchanged: it remains the primary cleanup for normal disconnections; the timer is a safety net for zombie sessions only. Closes #9432 Signed-off-by: Andrey Antukh Co-authored-by: Andrey Antukh --- CHANGES.md | 1 + mcp/packages/server/src/PenpotMcpServer.ts | 24 +++++++++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 41a05857fa..7994247dc1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -13,6 +13,7 @@ ### :bug: Bugs fixed +- Fix MCP SSE sessions leaking memory on zombie connections by adding inactivity timeout parity with Streamable HTTP sessions (by @bitloi) [Github #9432](https://github.com/penpot/penpot/issues/9432) - Fix missing `labels.open` translation (by @MilosM348) [Github #9320](https://github.com/penpot/penpot/pull/9320) - Harden Nginx responses with standard security headers and hide upstream `X-Powered-By` headers - Fix plugin API `shape.fills` and `shape.strokes` arrays being read-only [Github #8357](https://github.com/penpot/penpot/issues/8357) diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index 47a3ee3355..3d1e23c7cf 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -45,7 +45,7 @@ class ToolInfo { export class PenpotMcpServer { /** - * Timeout, in minutes, for idle Streamable HTTP sessions before they are automatically closed and removed. + * Timeout, in minutes, for idle sessions (Streamable HTTP and SSE) before they are automatically closed and removed. */ private static readonly SESSION_TIMEOUT_MINUTES = 60; @@ -65,7 +65,10 @@ export class PenpotMcpServer { private readonly sessionContext = new AsyncLocalStorage(); private readonly streamableTransports: Record = {}; - private readonly sseTransports: Record = {}; + private readonly sseTransports: Record< + string, + { transport: SSEServerTransport; userToken?: string; lastActiveTime: number } + > = {}; public readonly host: string; public readonly port: number; @@ -179,7 +182,7 @@ export class PenpotMcpServer { } /** - * Starts a periodic timer that closes and removes Streamable HTTP sessions that have been + * Starts a periodic timer that closes and removes Streamable HTTP and SSE sessions that have been * idle for longer than {@link SESSION_TIMEOUT_MINUTES}. */ private startSessionTimeoutChecker(): void { @@ -195,8 +198,18 @@ export class PenpotMcpServer { removed++; } } + for (const [id, session] of Object.entries(this.sseTransports)) { + if (now - session.lastActiveTime > timeoutMs) { + this.logger.info(`Closing stale SSE session ${id}`); + session.transport.close(); + delete this.sseTransports[id]; + removed++; + } + } this.logger.info( - `Removed ${removed} stale session(s); total sessions remaining: ${Object.keys(this.streamableTransports).length}` + `Removed ${removed} stale session(s); total sessions remaining: ${ + Object.keys(this.streamableTransports).length + Object.keys(this.sseTransports).length + }` ); }, checkIntervalMs); } @@ -262,7 +275,7 @@ export class PenpotMcpServer { await this.sessionContext.run({ userToken }, async () => { const transport = new SSEServerTransport("/messages", res); - this.sseTransports[transport.sessionId] = { transport, userToken }; + this.sseTransports[transport.sessionId] = { transport, userToken, lastActiveTime: Date.now() }; const server = this.createMcpServer(); await server.connect(transport); @@ -281,6 +294,7 @@ export class PenpotMcpServer { const session = this.sseTransports[sessionId]; if (session) { + session.lastActiveTime = Date.now(); await this.sessionContext.run({ userToken: session.userToken }, async () => { await session.transport.handlePostMessage(req, res, req.body); });