diff --git a/.serena/memories/mcp/core.md b/.serena/memories/mcp/core.md index 7aeb784470..9d55a13105 100644 --- a/.serena/memories/mcp/core.md +++ b/.serena/memories/mcp/core.md @@ -94,3 +94,9 @@ In the normal Penpot devenv MCP path, the browser plugin does not discover or ro The live plugin connection registry is in-memory inside each MCP server process (`PluginBridge.connectedClients` / `clientsByToken`). The database only stores MCP access tokens and profile props such as `mcp-enabled`; it does not manage which plugin is connected to which MCP server. For parallel devenvs, prefer same-origin MCP routing: each Penpot instance should expose `/mcp/ws` through its own nginx/Caddy path to the MCP server running inside the same main container. Keep container-internal ports fixed (MCP defaults `4401/4402/4403`, backend/exporter/frontend defaults, etc.) and only offset host-side published ports per instance. If internal ports are offset, hardcoded local proxy config such as `docker/devenv/files/nginx.conf` will misroute unless templated too. + +## Plugin reconnect policy + +- The plugin treats WebSocket close code `1008` (policy violation) as terminal: it stops auto-reconnecting and stays disconnected until the user explicitly reconnects. Other close codes keep the capped-backoff retry. The decision lives in `ReconnectPolicy.ts` (`shouldReconnectAfterClose`), kept as a pure module so it is unit-testable without DOM/CSS. +- The MCP server emits `1008` for a duplicate connection on the same user token (`PluginBridge`) and for a missing `userToken` in multi-user mode. +- A tab rejected with `1008` never reaches `connected`, so the frontend's 60s reconnect watcher (`start-reconnect-watcher` in `app.main.data.workspace.mcp`, started only on `connected`) does not engage; recovery is manual via "Connect here". diff --git a/mcp/packages/plugin/src/ReconnectPolicy.test.ts b/mcp/packages/plugin/src/ReconnectPolicy.test.ts new file mode 100644 index 0000000000..4fe356ce4a --- /dev/null +++ b/mcp/packages/plugin/src/ReconnectPolicy.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { WS_CLOSE_POLICY_VIOLATION, shouldReconnectAfterClose } from "./ReconnectPolicy.ts"; + +test("names the policy violation close code used by the MCP server", () => { + assert.equal(WS_CLOSE_POLICY_VIOLATION, 1008); +}); + +test("does not reconnect after a policy violation close", () => { + assert.equal(shouldReconnectAfterClose(WS_CLOSE_POLICY_VIOLATION), false); +}); + +test("reconnects after transient close codes", () => { + const transientCodes = [ + { code: 1000, meaning: "normal closure" }, + { code: 1001, meaning: "going away" }, + { code: 1005, meaning: "no status received" }, + { code: 1006, meaning: "abnormal closure" }, + ]; + + for (const { code, meaning } of transientCodes) { + assert.equal(shouldReconnectAfterClose(code), true, `expected reconnect for ${code} (${meaning})`); + } +}); diff --git a/mcp/packages/plugin/src/ReconnectPolicy.ts b/mcp/packages/plugin/src/ReconnectPolicy.ts new file mode 100644 index 0000000000..9d2947894d --- /dev/null +++ b/mcp/packages/plugin/src/ReconnectPolicy.ts @@ -0,0 +1,18 @@ +/** + * WebSocket close code the MCP server uses for policy violations. + * + * The server rejects a second plugin connection for the same user token, and + * connections missing a token in multi-user mode, with this code. + */ +export const WS_CLOSE_POLICY_VIOLATION = 1008; + +/** + * Returns whether a WebSocket close should trigger an automatic reconnect. + * + * Policy violations are terminal: the server refuses the connection again + * immediately, so retrying only repeats the rejection. Other closes (for + * example a dropped or restarted server) are retried with backoff. + */ +export function shouldReconnectAfterClose(code: number): boolean { + return code !== WS_CLOSE_POLICY_VIOLATION; +} diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts index 4b57738974..f787cd9298 100644 --- a/mcp/packages/plugin/src/main.ts +++ b/mcp/packages/plugin/src/main.ts @@ -1,4 +1,5 @@ import "./style.css"; +import { shouldReconnectAfterClose } from "./ReconnectPolicy"; /** * the maximum allowed size for task responses sent back to the MCP server in the integrated remote MCP mode. @@ -255,7 +256,7 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { updateCurrentTask(null); } ws = null; - if (event.code === 1008) { + if (!shouldReconnectAfterClose(event.code)) { // Policy violation (e.g. duplicate connection for the same user // token - another tab already holds the connection). Retrying // would be refused again immediately, so stay disconnected