diff --git a/.github/workflows/tests-mcp.yml b/.github/workflows/tests-mcp.yml index 9e622ca83a..6f8eadcadd 100644 --- a/.github/workflows/tests-mcp.yml +++ b/.github/workflows/tests-mcp.yml @@ -49,4 +49,4 @@ jobs: - name: Tests working-directory: ./mcp run: | - pnpm -r run test; + pnpm run test; diff --git a/docs/mcp/index.md b/docs/mcp/index.md index 466144b3f1..4d2fa76fc0 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -147,6 +147,27 @@ If you just want to try Penpot AI workflows quickly through the MCP, follow this Once all five steps are done, your AI client should list Penpot tools. +### Keep the Penpot tab active + +The MCP plugin runs inside your Penpot browser tab. If the browser puts that tab to sleep, freezes it, or unloads it to save memory, the MCP server cannot run tasks in Penpot until the tab wakes up again. + +When this happens, MCP fails fast instead of waiting for a long task timeout: + +* In Chrome and Chromium-based browsers, the plugin can report when the tab is being frozen. +* In Firefox, Safari, and other browsers that do not expose the same freeze event, MCP uses plugin heartbeats. If the browser stops running the plugin JavaScript, the heartbeat becomes stale and the MCP server reports that the Penpot tab appears to be suspended. +* If the browser unloads the tab completely, the plugin disconnects and MCP reports that no Penpot plugin instance is connected. + +To recover, open or focus the Penpot tab again, wait until MCP reconnects, and retry the prompt. + +To reduce the chances of the browser putting Penpot to sleep during long MCP sessions: + +| Browser | Recommended setting | +| --- | --- | +| Chrome | Go to **Settings → Performance → Always keep these sites active** and add your Penpot site. Pinning the Penpot tab also helps prevent Chrome tab deactivation. | +| Edge | Go to **Settings → System and performance** and add your Penpot site to the list of sites that should never be put to sleep, if that option is available in your Edge version. | +| Firefox | Firefox does not provide the same per-site keep-awake control. If tab unloading is a problem, advanced users can disable tab unloading with `browser.tabs.unloadOnLowMemory=false` in `about:config`, but this can increase memory use. | +| Safari | Safari does not provide a comparable per-site keep-awake setting. Keep the Penpot tab open and active during long MCP sessions. | + ### First prompts to try After connecting, start with **read-only prompts** to confirm everything works and to understand what the agent can see: diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index 55d15e4852..fde7e22d6f 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -20,7 +20,10 @@ [beicon.v2.core :as rx] [potok.v2.core :as ptk])) -(def retry-interval 10000) +(def reconnect-fallback-interval 60000) + +(def reconnect-fallback-statuses + #{"disconnected" "error"}) (log/set-level! :info) @@ -54,11 +57,13 @@ (reset! interval-sub (ts/interval - retry-interval + reconnect-fallback-interval (fn [] - ;; Try to reconnect if active and not connected - (when-not (contains? #{"connecting" "connected"} - (-> @st/state :mcp :connection-status)) + ;; Slow app-level fallback. The plugin owns normal WebSocket + ;; reconnects; this only restarts it if the app remains in a + ;; failed connection state. + (when (contains? reconnect-fallback-statuses + (-> @st/state :mcp :connection-status)) (.log js/console "Reconnecting to MCP...") (st/emit! (ptk/data-event ::connect)))))))) diff --git a/mcp/README.md b/mcp/README.md index 3efc6255e7..bc6b2205cb 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -154,6 +154,11 @@ This bootstrap command will: > [!IMPORTANT] > Do not close the plugin's UI while using the MCP server, as this will close the connection. +> Also keep the Penpot tab active during long MCP sessions. Browsers may freeze, suspend, +> or unload inactive tabs to save resources; when that happens, the MCP server will reject +> tasks until the tab wakes up or reconnects. In Chrome, add your Penpot site to +> **Settings → Performance → Always keep these sites active** or pin the tab to reduce +> tab deactivation. ### 3. Connect an MCP Client diff --git a/mcp/package.json b/mcp/package.json index ff37ae65c4..2054f5c79f 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -15,7 +15,8 @@ "bootstrap": "pnpm -r install && pnpm run build && pnpm run start", "bootstrap:multi-user": "pnpm -r install && pnpm run build && pnpm run start:multi-user", "fmt": "prettier --write packages/", - "fmt:check": "prettier --check packages/" + "fmt:check": "prettier --check packages/", + "test": "pnpm -r run test" }, "repository": { "type": "git", diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts index 94210da288..4cd19e1947 100644 --- a/mcp/packages/plugin/src/main.ts +++ b/mcp/packages/plugin/src/main.ts @@ -17,6 +17,18 @@ document.body.dataset.theme = searchParams.get("theme") ?? "light"; // WebSocket connection to the MCP server let ws: WebSocket | null = null; +const HEARTBEAT_INTERVAL_MS = 10_000; +const RECONNECT_BASE_DELAY_MS = 1_000; +const RECONNECT_MAX_DELAY_MS = 30_000; + +// transport-level reconnect state for the plugin WebSocket +let shouldReconnect = false; +let lastConnectionUrl: string | undefined; +let lastConnectionToken: string | undefined; +let reconnectAttempts = 0; +let reconnectTimer: ReturnType | null = null; +let heartbeatTimer: ReturnType | null = null; + /** * indicates whether the plugin is running with the Penpot-integrated remote MCP server enabled * (as opposed to a local server used with the explicitly loaded plugin); @@ -118,14 +130,82 @@ function sendTaskResponse(response: any): void { } } +/** + * Emits a liveness signal from the plugin event loop. + * + * WebSocket ping/pong is not enough here: browsers can answer protocol pings while + * page JavaScript is frozen and unable to run MCP tasks. + */ +function sendHeartbeat(): boolean { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "heartbeat" })); + return true; + } + return false; +} + +/** Starts heartbeat emission for the active WebSocket. */ +function startHeartbeat(): void { + stopHeartbeat(); + heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS); +} + +/** Stops heartbeat emission for the active WebSocket. */ +function stopHeartbeat(): void { + if (heartbeatTimer !== null) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } +} + +/** + * Delay before the next reconnect, using capped exponential backoff. + * + * Keeps recovery fast for short drops without hammering an unavailable server. + */ +function computeReconnectDelay(attempts: number): number { + return Math.min(RECONNECT_BASE_DELAY_MS * 2 ** attempts, RECONNECT_MAX_DELAY_MS); +} + +/** Schedules one WebSocket reconnect attempt with backoff. */ +function scheduleReconnect(): void { + if (!shouldReconnect || reconnectTimer !== null) { + return; + } + const delay = computeReconnectDelay(reconnectAttempts); + reconnectAttempts++; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + if (shouldReconnect && ws?.readyState !== WebSocket.OPEN && ws?.readyState !== WebSocket.CONNECTING) { + connectToMcpServer(lastConnectionUrl, lastConnectionToken); + } + }, delay); +} + +/** Cancels pending reconnection and resets backoff. */ +function cancelReconnect(): void { + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + reconnectAttempts = 0; +} + /** * Establishes a WebSocket connection to the MCP server. */ function connectToMcpServer(baseUrl?: string, token?: string): void { + shouldReconnect = true; + lastConnectionUrl = baseUrl; + lastConnectionToken = token; + if (ws?.readyState === WebSocket.OPEN) { updateConnectionStatus("connected", "Connected"); return; } + if (ws?.readyState === WebSocket.CONNECTING) { + return; + } try { let wsUrl = baseUrl || PENPOT_MCP_WEBSOCKET_URL; @@ -139,6 +219,8 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { updateConnectionStatus("connecting", "Connecting..."); ws.onopen = () => { + cancelReconnect(); + startHeartbeat(); setTimeout(() => { if (ws) { console.log("Connected to MCP server"); @@ -164,7 +246,8 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { }; ws.onclose = (event: CloseEvent) => { - // If we've send the error update we don't send the disconnect as well + stopHeartbeat(); + // keep the explicit error state if one was already shown if (!wsError) { console.log("Disconnected from MCP server"); const label = event.reason ? `Disconnected: ${event.reason}` : "Disconnected"; @@ -172,6 +255,7 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { updateCurrentTask(null); } ws = null; + scheduleReconnect(); }; ws.onerror = (error) => { @@ -188,6 +272,14 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { } } +/** Closes the socket without reconnecting. */ +function disconnectFromMcpServer(): void { + shouldReconnect = false; + cancelReconnect(); + stopHeartbeat(); + ws?.close(); +} + copyCodeBtn?.addEventListener("click", () => { const code = executedCodeEl?.value; if (!code) return; @@ -203,7 +295,7 @@ connectBtn?.addEventListener("click", () => { }); disconnectBtn?.addEventListener("click", () => { - ws?.close(); + disconnectFromMcpServer(); }); // Listen plugin.ts messages @@ -224,7 +316,7 @@ window.addEventListener("message", (event) => { } } if (event.data.type === "stop-server") { - ws?.close(); + disconnectFromMcpServer(); } else if (event.data.source === "penpot") { document.body.dataset.theme = event.data.theme; } else if (event.data.type === "task-response") { @@ -233,4 +325,38 @@ window.addEventListener("message", (event) => { } }); +/** Sends a heartbeat or reconnects after the tab becomes active again. */ +function handleTabResumed(): void { + if (!shouldReconnect) { + return; + } + if (ws?.readyState === WebSocket.OPEN) { + sendHeartbeat(); + } else if (ws?.readyState !== WebSocket.CONNECTING) { + cancelReconnect(); + connectToMcpServer(lastConnectionUrl, lastConnectionToken); + } +} + +// Chrome supports freeze/resume; Firefox does not. That is acceptable because +// Firefox still supports visibilitychange, and stale heartbeats detect any tab +// suspension that prevents plugin JavaScript from running tasks. + +// Chrome: about to pause page JavaScript. +document.addEventListener("freeze", () => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "freeze" })); + } +}); + +// Chrome: frozen page resumed. +document.addEventListener("resume", handleTabResumed); + +// Chrome and Firefox: tab visibility changed. +document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") { + handleTabResumed(); + } +}); + parent.postMessage({ type: "ui-initialized" }, "*"); diff --git a/mcp/packages/server/package.json b/mcp/packages/server/package.json index c3a3a04e47..29f1ec467b 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -12,6 +12,7 @@ "start:multi-user": "node dist/index.js --multi-user", "start:dev": "node --import ts-node/register src/index.ts", "start:dev:multi-user": "node --loader ts-node/esm src/index.ts --multi-user", + "test": "tsx --test src/*.test.ts", "test:integration:export-sema": "tsx scripts/integration-test-export-image-semaphore.ts", "types:check": "tsc --noEmit", "clean": "rm -rf dist/" diff --git a/mcp/packages/server/src/PluginBridge.test.ts b/mcp/packages/server/src/PluginBridge.test.ts new file mode 100644 index 0000000000..b0d15936be --- /dev/null +++ b/mcp/packages/server/src/PluginBridge.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { assertPluginResponsive, HEARTBEAT_STALE_THRESHOLD_MS } from "./PluginBridge"; + +test("passes for a responsive connection with a recent heartbeat", () => { + const now = 1_000_000; + assert.doesNotThrow(() => assertPluginResponsive({ frozen: false, lastHeartbeat: now - 5_000 }, now)); +}); + +test("passes when the heartbeat age is exactly at the threshold", () => { + const now = 1_000_000; + const lastHeartbeat = now - HEARTBEAT_STALE_THRESHOLD_MS; + assert.doesNotThrow(() => assertPluginResponsive({ frozen: false, lastHeartbeat }, now)); +}); + +test("throws a frozen-specific error when the tab reported it is being frozen", () => { + const now = 1_000_000; + assert.throws(() => assertPluginResponsive({ frozen: true, lastHeartbeat: now }, now), /has been frozen/); +}); + +test("throws a suspended error when no heartbeat has arrived within the threshold", () => { + const now = 1_000_000; + const lastHeartbeat = now - (HEARTBEAT_STALE_THRESHOLD_MS + 1); + assert.throws( + () => assertPluginResponsive({ frozen: false, lastHeartbeat }, now), + /appears to be suspended by the browser/ + ); +}); + +test("includes the heartbeat age, in seconds, in the suspended error", () => { + const now = 1_000_000; + const lastHeartbeat = now - 45_000; + assert.throws(() => assertPluginResponsive({ frozen: false, lastHeartbeat }, now), /no heartbeat for 45s/); +}); + +test("honours a custom stale threshold", () => { + const now = 1_000_000; + const lastHeartbeat = now - 2_000; + + assert.doesNotThrow(() => assertPluginResponsive({ frozen: false, lastHeartbeat }, now)); + assert.throws( + () => assertPluginResponsive({ frozen: false, lastHeartbeat }, now, 1_000), + /appears to be suspended by the browser/ + ); +}); diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts index 1c24547b8f..19a3a77545 100644 --- a/mcp/packages/server/src/PluginBridge.ts +++ b/mcp/packages/server/src/PluginBridge.ts @@ -9,12 +9,58 @@ import type { RedisBridge } from "./RedisBridge"; const KEEP_ALIVE_TIME = 30000; // 30 seconds -interface ClientConnection { +/** + * Maximum plugin heartbeat age before a connection is stale. + * + * This uses plugin heartbeats rather than WebSocket pongs because the browser can answer + * protocol pings while the tab's JavaScript event loop is frozen. + */ +export const HEARTBEAT_STALE_THRESHOLD_MS = 30000; + +/** + * Observable liveness state of a plugin connection. + */ +export interface PluginLivenessState { + /** timestamp of the last plugin message, in ms since epoch. */ + lastHeartbeat: number; + /** whether the plugin reported a browser freeze. */ + frozen: boolean; +} + +interface ClientConnection extends PluginLivenessState { socket: WebSocket; userToken: string | null; pingInterval: NodeJS.Timeout; } +/** + * Throws if the plugin tab cannot currently run tasks. + * + * A socket can stay open while the page event loop is paused, so task dispatch must check + * plugin-level liveness before sending work. + */ +export function assertPluginResponsive( + state: PluginLivenessState, + now: number, + staleThresholdMs: number = HEARTBEAT_STALE_THRESHOLD_MS +): void { + if (state.frozen) { + throw new Error( + `The Penpot plugin tab has been frozen by the browser and cannot run tasks. ` + + `Please click/focus the Penpot tab to wake it, then retry.` + ); + } + + const heartbeatAge = now - state.lastHeartbeat; + if (heartbeatAge > staleThresholdMs) { + throw new Error( + `The Penpot plugin tab appears to be suspended by the browser (no heartbeat for ` + + `${Math.round(heartbeatAge / 1000)}s). Please click/focus the Penpot tab to wake it, ` + + `then retry.` + ); + } +} + /** * Manages WebSocket connections to Penpot plugin instances and handles plugin tasks * over these connections. @@ -79,7 +125,13 @@ export class PluginBridge { }, KEEP_ALIVE_TIME); // register the client connection with both indexes - const connection: ClientConnection = { socket: ws, userToken, pingInterval }; + const connection: ClientConnection = { + socket: ws, + userToken, + pingInterval, + lastHeartbeat: Date.now(), + frozen: false, + }; this.connectedClients.set(ws, connection); if (userToken) { // ensure only one connection per userToken @@ -107,8 +159,20 @@ export class PluginBridge { ws.on("message", (data: Buffer) => { this.logger.debug("Received WebSocket message: %s", data.toString()); try { - const response: PluginTaskResponse = JSON.parse(data.toString()); - this.handlePluginTaskResponse(response); + // any plugin message proves the page event loop is running + connection.lastHeartbeat = Date.now(); + + const message = JSON.parse(data.toString()); + if (message?.type === "freeze") { + connection.frozen = true; + this.logger.info("Plugin tab reported it is being frozen by the browser"); + return; + } + connection.frozen = false; + if (message?.type === "heartbeat") { + return; + } + this.handlePluginTaskResponse(message as PluginTaskResponse); } catch (error) { this.logger.error(error, "Failure while processing WebSocket message"); } @@ -293,6 +357,9 @@ export class PluginBridge { throw new Error(`Plugin instance is disconnected. Task could not be sent.`); } + // the socket can be open while browser-throttled plugin JS cannot run tasks + assertPluginResponsive(target, Date.now()); + // register the task for result correlation, then send over the socket this.pendingTasks.set(task.id, task); target.socket.send(JSON.stringify(task.toRequest()));