From f4066e06c6258bb0663ff6309d8b68b29cf0bf30 Mon Sep 17 00:00:00 2001 From: RaccoonsNMangos Date: Fri, 19 Jun 2026 00:52:33 -0600 Subject: [PATCH] fix(mcp): don't close the MCP session on a missed heartbeat ping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Problem In HTTP/streamable transport, `startHeartbeat` pings the client and calls `server.close()` if the ping isn't answered within 5s, reaping the session and its browser context. Any client that doesn't promptly answer server-initiated pings — long-running tool calls, or aggregating proxies/gateways — gets `session terminated (404)`. Fixes microsoft/playwright-mcp#1293 (related: #982, #1140, #1307). Raising the 5s timeout doesn't help: `server.ping()` still rejects on the SDK's ~60s request timeout, which hits the same `.catch(() => server.close())`. The reap is caused by closing on a missed ping, not by the race-timeout value. ### Change Keep the heartbeat as a keepalive but no longer close the session on a missed ping. Dead transports are still cleaned up by `transport.onclose`, so a missed application-level ping no longer reaps a live session. The ping continues on the same 3s cadence in all cases. ### Alternatives considered - Making the timeout configurable (#982) — doesn't help clients that *never* answer pings. - Closing only after N consecutive misses — same limitation. Happy to adjust to whatever matches the heartbeat's intended design. Signed-off-by: RaccoonsNMangos --- .../playwright-core/src/tools/utils/mcp/server.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/playwright-core/src/tools/utils/mcp/server.ts b/packages/playwright-core/src/tools/utils/mcp/server.ts index 9404ff33e0871..a854a46d6c25d 100644 --- a/packages/playwright-core/src/tools/utils/mcp/server.ts +++ b/packages/playwright-core/src/tools/utils/mcp/server.ts @@ -156,13 +156,16 @@ const startHeartbeat = (server: ServerType) => { Promise.race([ server.ping(), new Promise((_, reject) => setTimeout(() => reject(new Error('ping timeout')), 5000)), - ]).then(() => { + ]).catch(() => { + // A missed application-level ping does not mean the connection is dead. Long-running + // tool calls, and HTTP clients/proxies that don't answer server-initiated pings, would + // otherwise have their session (and browser context) reaped while perfectly alive. + // Dead transports are already cleaned up via transport.onclose, so keep the heartbeat + // purely as a keepalive and keep beating. + }).finally(() => { setTimeout(beat, 3000); - }).catch(() => { - void server.close(); }); }; - beat(); };