diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs
index 58ce2f5060..07f766ab61 100644
--- a/frontend/src/app/main/data/workspace/mcp.cljs
+++ b/frontend/src/app/main/data/workspace/mcp.cljs
@@ -17,6 +17,7 @@
[app.main.data.profile :as du]
[app.main.store :as st]
[app.plugins.register :as preg]
+ [app.util.globals :as globals]
[app.util.timers :as ts]
[beicon.v2.core :as rx]
[beicon.v2.operators :as rxo]
@@ -44,6 +45,32 @@
(defonce interval-sub
(atom nil))
+;; Resolves the promise that keeps the session Web Lock held; nil when none is.
+(defonce ^:private session-lock-release
+ (atom nil))
+
+(defn- hold-session-lock
+ "Holds a shared Web Lock while the MCP connection is requested. Chrome
+ does not freeze a page that holds a Web Lock, and a frozen page cannot
+ run the plugin."
+ []
+ (when-let [locks (and (nil? @session-lock-release)
+ (unchecked-get globals/navigator "locks"))]
+ (let [released (js/Promise. (fn [resolve] (reset! session-lock-release resolve)))
+ release @session-lock-release]
+ (-> (.request locks "penpot-mcp-session" #js {:mode "shared"} (constantly released))
+ (.catch (fn [cause]
+ ;; lets a later connection request try again, unless a
+ ;; newer lock request has already replaced this one
+ (compare-and-set! session-lock-release release nil)
+ (log/warn :hint "unable to hold the MCP session lock" :cause cause)))))))
+
+(defn- release-session-lock
+ []
+ (when-let [release @session-lock-release]
+ (reset! session-lock-release nil)
+ (release)))
+
(defn connect-mcp
[]
(ptk/reify ::connect-mcp
@@ -60,7 +87,12 @@
(watch [_ state _]
(if (get-in state [:mcp :connection-requested])
(rx/of (ptk/data-event ::connect))
- (rx/empty)))))
+ (rx/empty)))
+
+ ptk/EffectEvent
+ (effect [_ state _]
+ (when (get-in state [:mcp :connection-requested])
+ (hold-session-lock)))))
(defn- start-reconnect-watcher
[]
@@ -133,8 +165,38 @@
ptk/EffectEvent
(effect [_ _ _]
+ (release-session-lock)
(stop-reconnect-watcher!))))
+(defn- extension-hello?
+ "Checks if a window message is a browser extension's request for the
+ MCP session, posted on this page by its content script."
+ [event]
+ (and (identical? (unchecked-get event "source") globals/window)
+ (= (unchecked-get event "origin") (.-origin globals/location))
+ (= "penpot-mcp-extension:hello"
+ (some-> (unchecked-get event "data") (unchecked-get "type")))))
+
+(defn extension-status
+ "Returns the connection status to announce to a browser extension. The
+ plugin also reports `disconnected` while it reconnects, so the
+ extension, which forgets a session on `disconnected`, only hears it
+ once the user has stopped the connection."
+ [status connection-requested?]
+ (if (and (= status "disconnected") connection-requested?)
+ "connecting"
+ status))
+
+(defn- announce-session
+ "Posts the MCP session of this page to a browser extension, which keeps
+ this tab from being discarded."
+ [status session-id]
+ (.postMessage globals/window
+ #js {:type "penpot-mcp:session"
+ :sessionId session-id
+ :status status}
+ (.-origin globals/location)))
+
(defn- init-mcp-plugin
"Waits the plugin runtime and initializes the bundled MCP plugin."
[{:keys [token]}]
@@ -151,6 +213,19 @@
(rx/filter (ptk/type? ::init) stream))
active? (atom true)
+
+ ;; Whether a browser extension asked for the session; nothing is
+ ;; posted on the page before that.
+ announce? (atom false)
+
+ on-message
+ (fn [event]
+ (when (extension-hello? event)
+ (reset! announce? true)
+ (let [{:keys [connection-status connection-requested session-id]} (get @st/state :mcp)]
+ (announce-session (extension-status connection-status connection-requested)
+ session-id))))
+
extension #js {:getToken (constantly token)
:getServerUrl #(str cf/mcp-ws-uri)
:isConnectionRequested #(and @active?
@@ -162,6 +237,9 @@
(get-in @st/state [:mcp :connection-requested]))
(start-reconnect-watcher))
(st/emit! (update-mcp-connection-status status session-id))
+ (when @announce?
+ (announce-session (extension-status status (get-in @st/state [:mcp :connection-requested]))
+ session-id))
(log/info :hint "MCP STATUS" :status status)))
:on
@@ -182,8 +260,16 @@
(rx/take 1)
(rx/subs! (fn [_]
(reset! active? false)
+ (.removeEventListener globals/window "message" on-message)
+ (when @announce?
+ (announce-session "disconnected" nil))
+ (release-session-lock)
(stop-reconnect-watcher!)
(dp/close-plugin! default-manifest))))
+ (.addEventListener globals/window "message" on-message)
+ ;; A content script that said hello before this listener existed
+ ;; says it again
+ (.postMessage globals/window #js {:type "penpot-mcp:ready"} (.-origin globals/location))
(dp/start-plugin! manifest #js {:mcp extension})))))
(defn- stop-mcp-plugin
diff --git a/frontend/src/app/util/globals.js b/frontend/src/app/util/globals.js
index 5ac6cd3130..8b398ba53d 100644
--- a/frontend/src/app/util/globals.js
+++ b/frontend/src/app/util/globals.js
@@ -84,6 +84,9 @@ goog.scope(function () {
forward() {},
go() {},
};
+ // `app.main.data.workspace.mcp` talks to a browser extension through
+ // window messages; nothing receives them here.
+ mockWindow.postMessage = function () {};
return mockWindow;
}
})();
diff --git a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs
index 802be3ae2f..77c685036d 100644
--- a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs
+++ b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs
@@ -13,6 +13,7 @@
[app.main.data.profile :as du]
[app.main.data.workspace.mcp :as mcp]
[app.plugins.register :as preg]
+ [app.util.globals :as globals]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.async :as a]
@@ -58,6 +59,18 @@
(t/is (false? (get-in result [:profile :props :mcp-enabled])))
(t/is (false? (get-in result [:mcp :enabled]))))))
+(t/deftest test-extension-status
+ (t/testing "keeps a session the plugin is reconnecting"
+ (t/is (= "connecting" (mcp/extension-status "disconnected" true))))
+
+ (t/testing "reports the disconnect once the user has stopped the connection"
+ (t/is (= "disconnected" (mcp/extension-status "disconnected" false)))
+ (t/is (= "disconnected" (mcp/extension-status "disconnected" nil))))
+
+ (t/testing "passes other statuses through"
+ (t/is (= "connected" (mcp/extension-status "connected" true)))
+ (t/is (= "error" (mcp/extension-status "error" true)))))
+
(t/deftest test-update-mcp-connection-status
(t/testing "sets connection status to connected"
(let [state {:mcp {:connection-requested true :connection-status "disconnected"}}
@@ -171,6 +184,47 @@
:on-next #(swap! events conj (ptk/type %))))
(t/is (= [:app.main.data.workspace.mcp/connect] @events))))
+(defn- connect
+ "Runs the effect of a connection request made with the given MCP state."
+ [mcp-state]
+ (let [event (mcp/connect-mcp)]
+ (ptk/effect event (ptk/update event {:mcp mcp-state}) nil)))
+
+(t/deftest ^:async test-session-lock-follows-connection-request
+ (let [requests (atom [])
+ released (atom 0)
+ locks #js {:request (fn [lock-name options callback]
+ (swap! requests conj [lock-name (.-mode options)])
+ (.then (callback nil) #(swap! released inc)))}]
+ (await
+ (mock/with-mocks*
+ {globals/navigator #js {:locks locks}}
+ (connect {:enabled false})
+ (t/is (empty? @requests))
+ (connect {:enabled true :token-valid true})
+ (connect {:enabled true :token-valid true})
+ (t/is (= [["penpot-mcp-session" "shared"]] @requests))
+ (ptk/effect (mcp/user-disconnect-mcp) nil nil)
+ (await (a/settle))
+ (t/is (= 1 @released))
+ (ptk/effect (mcp/user-disconnect-mcp) nil nil)
+ (await (a/settle))
+ (t/is (= 1 @released))))))
+
+(t/deftest ^:async test-session-lock-retried-after-failed-request
+ (let [requests (atom 0)
+ locks #js {:request (fn [_ _ _]
+ (swap! requests inc)
+ (js/Promise.reject (js/Error. "lock request denied")))}]
+ (await
+ (mock/with-mocks*
+ {globals/navigator #js {:locks locks}}
+ (connect {:enabled true :token-valid true})
+ (await (a/settle))
+ (connect {:enabled true :token-valid true})
+ (await (a/settle))
+ (t/is (= 2 @requests))))))
+
(t/deftest test-disconnect-clears-connection-intent
(let [state {:mcp {:connection-requested true
:connection-status "connecting"
diff --git a/mcp/packages/browser-extension/.gitignore b/mcp/packages/browser-extension/.gitignore
new file mode 100644
index 0000000000..f06235c460
--- /dev/null
+++ b/mcp/packages/browser-extension/.gitignore
@@ -0,0 +1,2 @@
+node_modules
+dist
diff --git a/mcp/packages/browser-extension/README.md b/mcp/packages/browser-extension/README.md
new file mode 100644
index 0000000000..21597628ae
--- /dev/null
+++ b/mcp/packages/browser-extension/README.md
@@ -0,0 +1,53 @@
+# Penpot MCP
+
+A Chrome and Firefox extension that keeps the browser from discarding Penpot tabs connected to
+MCP. A discarded tab reloads when the user returns to it, and loses its MCP session.
+
+## How it works
+
+- The content script asks the Penpot page for its MCP session. The page answers with the
+ session ID and connection status.
+- The background (a service worker in Chrome, an event page in Firefox) maps each session to
+ its tab and marks the tab as not discardable (`autoDiscardable: false`) until the session
+ ends.
+
+The popup lists the connected tabs.
+
+The icon is the Penpot logo; `public/icons/icon.svg` is its source. To redraw the PNGs after
+changing it:
+
+```sh
+for size in 16 32 48 128; do
+ rsvg-convert -w $size -h $size public/icons/icon.svg -o public/icons/icon-$size.png
+done
+```
+
+## Frozen tabs
+
+Chrome can also freeze a background page, which then cannot run the MCP plugin. The extension
+does not handle this: the Penpot page holds a shared Web Lock while its MCP connection is
+requested, and Chrome does not freeze a page holding one. Firefox does not freeze background
+pages.
+
+## Build and load
+
+From `mcp/`:
+
+```sh
+pnpm install
+pnpm --filter mcp-common run build
+pnpm --filter mcp-browser-extension run build
+```
+
+The build writes one extension per browser, in `dist/chrome` and `dist/firefox`.
+
+- **Chrome:** open `chrome://extensions`, turn on developer mode, choose "Load unpacked" and
+ pick `mcp/packages/browser-extension/dist/chrome`.
+- **Firefox** (140 or later): open `about:debugging#/runtime/this-firefox`, choose "Load
+ Temporary Add-on…" and pick `mcp/packages/browser-extension/dist/firefox/manifest.json`.
+ Firefox removes temporary add-ons when it closes.
+
+Reload Penpot tabs opened before the extension was loaded, so the content script runs in them.
+
+The content script runs on `https://design.penpot.app`, on the test environments under
+`https://*.penpot.dev` and on `localhost` at any port.
diff --git a/mcp/packages/browser-extension/package.json b/mcp/packages/browser-extension/package.json
new file mode 100644
index 0000000000..0f1c0fe8ae
--- /dev/null
+++ b/mcp/packages/browser-extension/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "mcp-browser-extension",
+ "private": true,
+ "version": "0.1.0",
+ "description": "Browser extension that keeps Penpot tabs connected to MCP from being discarded",
+ "type": "module",
+ "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61",
+ "scripts": {
+ "build": "tsc && node ./scripts/build.mjs",
+ "test": "tsx --test \"src/**/*.test.ts\"",
+ "types:check": "tsc",
+ "clean": "rm -rf dist/"
+ },
+ "devDependencies": {
+ "@penpot/mcp-common": "workspace:../common",
+ "@types/chrome": "^0.3.6",
+ "esbuild": "^0.28.2",
+ "tsx": "^4.23.15",
+ "typescript": "^6.0.3"
+ }
+}
diff --git a/mcp/packages/browser-extension/public/icons/icon-128.png b/mcp/packages/browser-extension/public/icons/icon-128.png
new file mode 100644
index 0000000000..a545f42dd5
Binary files /dev/null and b/mcp/packages/browser-extension/public/icons/icon-128.png differ
diff --git a/mcp/packages/browser-extension/public/icons/icon-16.png b/mcp/packages/browser-extension/public/icons/icon-16.png
new file mode 100644
index 0000000000..3e6eb3e20e
Binary files /dev/null and b/mcp/packages/browser-extension/public/icons/icon-16.png differ
diff --git a/mcp/packages/browser-extension/public/icons/icon-32.png b/mcp/packages/browser-extension/public/icons/icon-32.png
new file mode 100644
index 0000000000..31f2fe30e5
Binary files /dev/null and b/mcp/packages/browser-extension/public/icons/icon-32.png differ
diff --git a/mcp/packages/browser-extension/public/icons/icon-48.png b/mcp/packages/browser-extension/public/icons/icon-48.png
new file mode 100644
index 0000000000..f92dd70100
Binary files /dev/null and b/mcp/packages/browser-extension/public/icons/icon-48.png differ
diff --git a/mcp/packages/browser-extension/public/icons/icon.svg b/mcp/packages/browser-extension/public/icons/icon.svg
new file mode 100644
index 0000000000..b12e54c10e
--- /dev/null
+++ b/mcp/packages/browser-extension/public/icons/icon.svg
@@ -0,0 +1,6 @@
+
diff --git a/mcp/packages/browser-extension/public/popup.css b/mcp/packages/browser-extension/public/popup.css
new file mode 100644
index 0000000000..acbc7ba3dd
--- /dev/null
+++ b/mcp/packages/browser-extension/public/popup.css
@@ -0,0 +1,54 @@
+:root {
+ color-scheme: light dark;
+ --text: #1f1f1f;
+ --muted: #6b6b6b;
+ --background: #ffffff;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --text: #ededed;
+ --muted: #a0a0a0;
+ --background: #1f1f1f;
+ }
+}
+
+body {
+ width: 320px;
+ margin: 0;
+ padding: 12px 16px;
+ font:
+ 13px/1.4 system-ui,
+ sans-serif;
+ color: var(--text);
+ background: var(--background);
+}
+
+h1 {
+ margin: 0 0 12px;
+ font-size: 15px;
+}
+
+h2 {
+ margin: 16px 0 4px;
+ font-size: 12px;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+.note {
+ margin: 0;
+ color: var(--muted);
+}
+
+ul {
+ margin: 0;
+ padding-left: 18px;
+ overflow-wrap: anywhere;
+}
+
+li.empty {
+ list-style: none;
+ margin-left: -18px;
+ color: var(--muted);
+}
diff --git a/mcp/packages/browser-extension/public/popup.html b/mcp/packages/browser-extension/public/popup.html
new file mode 100644
index 0000000000..778829cfab
--- /dev/null
+++ b/mcp/packages/browser-extension/public/popup.html
@@ -0,0 +1,15 @@
+
+
+
+
+ Penpot MCP
+
+
+
+ Penpot MCP
+ The browser does not discard these tabs while they are connected to MCP.
+ Connected tabs
+
+
+
+
diff --git a/mcp/packages/browser-extension/scripts/build.mjs b/mcp/packages/browser-extension/scripts/build.mjs
new file mode 100644
index 0000000000..6d7b6b3069
--- /dev/null
+++ b/mcp/packages/browser-extension/scripts/build.mjs
@@ -0,0 +1,18 @@
+import { build } from "esbuild";
+import { cp, rm, writeFile } from "node:fs/promises";
+import { BROWSERS, createManifest } from "./manifest.mjs";
+
+const TARGETS = { chrome: "chrome116", firefox: "firefox140" };
+
+await rm("dist", { recursive: true, force: true });
+for (const browser of BROWSERS) {
+ const outdir = `dist/${browser}`;
+ const options = { bundle: true, target: TARGETS[browser], outdir, logLevel: "info" };
+ // Chrome loads the service worker as a module, Firefox the event page as a classic script
+ await build({ ...options, entryPoints: ["src/background.ts"], format: browser === "chrome" ? "esm" : "iife" });
+ await build({ ...options, entryPoints: ["src/popup.ts"], format: "esm" });
+ // content scripts load as classic scripts
+ await build({ ...options, entryPoints: ["src/content.ts"], format: "iife" });
+ await cp("public", outdir, { recursive: true });
+ await writeFile(`${outdir}/manifest.json`, JSON.stringify(createManifest(browser), null, 4) + "\n");
+}
diff --git a/mcp/packages/browser-extension/scripts/manifest.mjs b/mcp/packages/browser-extension/scripts/manifest.mjs
new file mode 100644
index 0000000000..5b11972c55
--- /dev/null
+++ b/mcp/packages/browser-extension/scripts/manifest.mjs
@@ -0,0 +1,61 @@
+/** Browsers the extension is built for, one directory of `dist/` each. */
+export const BROWSERS = ["chrome", "firefox"];
+
+const ICONS = {
+ 16: "icons/icon-16.png",
+ 32: "icons/icon-32.png",
+ 48: "icons/icon-48.png",
+ 128: "icons/icon-128.png",
+};
+
+const CONTENT_SCRIPTS = [
+ {
+ matches: ["https://design.penpot.app/*", "https://*.penpot.dev/*", "https://localhost/*", "http://localhost/*"],
+ js: ["content.js"],
+ run_at: "document_idle",
+ },
+];
+
+/**
+ * Creates the extension manifest for a browser.
+ *
+ * Chrome runs the background as a module service worker. Firefox runs it as an event page and
+ * needs an add-on ID.
+ *
+ * @param {"chrome" | "firefox"} browser - The browser to build for
+ */
+export function createManifest(browser) {
+ const common = {
+ manifest_version: 3,
+ name: "Penpot MCP",
+ version: "0.1.0",
+ description: "Keeps the browser from discarding Penpot tabs connected to MCP.",
+ icons: ICONS,
+ permissions: ["storage"],
+ action: {
+ default_title: "Penpot MCP",
+ default_popup: "popup.html",
+ default_icon: { 16: ICONS[16], 32: ICONS[32] },
+ },
+ content_scripts: CONTENT_SCRIPTS,
+ };
+ if (browser === "chrome") {
+ return {
+ ...common,
+ minimum_chrome_version: "116",
+ background: { service_worker: "background.js", type: "module" },
+ };
+ }
+ return {
+ ...common,
+ background: { scripts: ["background.js"] },
+ browser_specific_settings: {
+ gecko: {
+ id: "mcp-extension@penpot.app",
+ // the first version that knows data_collection_permissions
+ strict_min_version: "140.0",
+ data_collection_permissions: { required: ["none"] },
+ },
+ },
+ };
+}
diff --git a/mcp/packages/browser-extension/src/DiscardGuard.ts b/mcp/packages/browser-extension/src/DiscardGuard.ts
new file mode 100644
index 0000000000..a50b06d682
--- /dev/null
+++ b/mcp/packages/browser-extension/src/DiscardGuard.ts
@@ -0,0 +1,28 @@
+import type { SessionChange, SessionMapObserver } from "./SessionMap.ts";
+
+/** Browser control over the automatic discarding of tabs. */
+export interface DiscardControl {
+ setAutoDiscardable(tabId: number, autoDiscardable: boolean): Promise;
+}
+
+/**
+ * Keeps the browser from discarding tabs that hold an MCP session, since a discarded tab reloads
+ * and loses its MCP connection. Tabs regain automatic discarding when they stop holding a session.
+ */
+export class DiscardGuard implements SessionMapObserver {
+ constructor(private readonly control: DiscardControl) {}
+
+ sessionsChanged(change: SessionChange): void {
+ for (const tabId of change.tabsAdded) {
+ this.set(tabId, false);
+ }
+ for (const tabId of change.tabsRemoved) {
+ this.set(tabId, true);
+ }
+ }
+
+ private set(tabId: number, autoDiscardable: boolean): void {
+ // the tab may have been closed meanwhile
+ this.control.setAutoDiscardable(tabId, autoDiscardable).catch(() => {});
+ }
+}
diff --git a/mcp/packages/browser-extension/src/PageBridge.test.ts b/mcp/packages/browser-extension/src/PageBridge.test.ts
new file mode 100644
index 0000000000..afaeabd048
--- /dev/null
+++ b/mcp/packages/browser-extension/src/PageBridge.test.ts
@@ -0,0 +1,102 @@
+import assert from "node:assert/strict";
+import { beforeEach, test } from "node:test";
+import type { ContentMessage } from "./messages.ts";
+import { PageBridge, type PageWindow, type WorkerChannel } from "./PageBridge.ts";
+
+const ORIGIN = "https://design.penpot.app";
+
+/** Page recording what the bridge posts and dispatching events to it. */
+class FakePage implements PageWindow {
+ readonly location = { origin: ORIGIN };
+ readonly posted: { message: unknown; targetOrigin: string }[] = [];
+ private readonly listeners = new Map void)[]>();
+
+ postMessage(message: unknown, targetOrigin: string): void {
+ this.posted.push({ message, targetOrigin });
+ }
+
+ addEventListener(type: string, listener: (event: any) => void): void {
+ this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
+ }
+
+ dispatch(type: string, event: object = {}): void {
+ for (const listener of this.listeners.get(type) ?? []) listener(event);
+ }
+
+ /** Posts a message on the page as the Penpot app does. */
+ post(data: unknown, origin = ORIGIN, source: unknown = this): void {
+ this.dispatch("message", { data, origin, source });
+ }
+}
+
+/** Worker channel recording what the bridge sends. */
+class FakeWorker implements WorkerChannel {
+ readonly received: ContentMessage[] = [];
+
+ async sendMessage(message: ContentMessage): Promise {
+ this.received.push(message);
+ return undefined;
+ }
+}
+
+const announcement = { type: "penpot-mcp:session", sessionId: "abcdefghij", status: "connected" };
+
+let page: FakePage;
+let worker: FakeWorker;
+
+beforeEach(() => {
+ page = new FakePage();
+ worker = new FakeWorker();
+ new PageBridge(page, worker).start();
+});
+
+test("asks the page for its session on start, restricted to the page's origin", () => {
+ assert.deepEqual(page.posted, [{ message: { type: "penpot-mcp-extension:hello" }, targetOrigin: ORIGIN }]);
+});
+
+test("says hello again when the page reports it has started listening", () => {
+ page.post({ type: "penpot-mcp:ready" });
+ page.post({ type: "penpot-mcp:ready" }, "https://evil.example");
+ assert.equal(page.posted.length, 2);
+ assert.deepEqual(page.posted[1], { message: { type: "penpot-mcp-extension:hello" }, targetOrigin: ORIGIN });
+});
+
+test("forwards the page's session announcements to the worker", () => {
+ page.post(announcement);
+ assert.deepEqual(worker.received, [{ type: "session", announcement }]);
+});
+
+test("ignores announcements from other origins or other windows", () => {
+ page.post(announcement, "https://evil.example");
+ page.post(announcement, ORIGIN, {});
+ assert.deepEqual(worker.received, []);
+});
+
+test("ignores invalid announcements and its own hello", () => {
+ page.post({ ...announcement, status: "unknown" });
+ page.post({ type: "penpot-mcp-extension:hello" });
+ assert.deepEqual(worker.received, []);
+});
+
+test("reports a hidden page and asks again when it returns from the back/forward cache", () => {
+ page.dispatch("pagehide");
+ page.dispatch("pageshow", { persisted: false });
+ assert.equal(page.posted.length, 1);
+ page.dispatch("pageshow", { persisted: true });
+ assert.deepEqual(worker.received, [{ type: "page-hidden" }]);
+ assert.equal(page.posted.length, 2);
+});
+
+test("keeps working when the worker cannot be reached", () => {
+ const failing = new FakePage();
+ new PageBridge(failing, {
+ sendMessage: () => {
+ throw new Error("Extension context invalidated.");
+ },
+ }).start();
+ failing.post(announcement);
+ new PageBridge(failing, {
+ sendMessage: () => Promise.reject(new Error("No receiver")),
+ }).start();
+ failing.post(announcement);
+});
diff --git a/mcp/packages/browser-extension/src/PageBridge.ts b/mcp/packages/browser-extension/src/PageBridge.ts
new file mode 100644
index 0000000000..42a0a54887
--- /dev/null
+++ b/mcp/packages/browser-extension/src/PageBridge.ts
@@ -0,0 +1,69 @@
+import type { ExtensionHello } from "@penpot/mcp-common";
+import { parseAnnouncement, type ContentMessage } from "./messages.ts";
+
+/** Page operations the bridge uses; the content script passes its window. */
+export interface PageWindow {
+ readonly location: { readonly origin: string };
+ postMessage(message: unknown, targetOrigin: string): void;
+ addEventListener(type: string, listener: (event: any) => void): void;
+}
+
+/** Channel from the content script to the service worker. */
+export interface WorkerChannel {
+ sendMessage(message: ContentMessage): Promise;
+}
+
+/**
+ * Relays the MCP session announced by a Penpot page to the extension's service worker.
+ *
+ * The bridge asks the page for its session on load, when the page reports it has started
+ * listening, and when the page returns from the back/forward cache. It accepts only messages the
+ * page posted on its own window from its own origin.
+ */
+export class PageBridge {
+ constructor(
+ private readonly page: PageWindow,
+ private readonly worker: WorkerChannel
+ ) {}
+
+ /** Starts listening to the page and asks it for its session. */
+ start(): void {
+ this.page.addEventListener("message", (event: MessageEvent) => this.handleMessage(event));
+ this.page.addEventListener("pagehide", () => this.send({ type: "page-hidden" }));
+ this.page.addEventListener("pageshow", (event: PageTransitionEvent) => {
+ if (event.persisted) {
+ this.sayHello();
+ }
+ });
+ this.sayHello();
+ }
+
+ private sayHello(): void {
+ const hello: ExtensionHello = { type: "penpot-mcp-extension:hello" };
+ this.page.postMessage(hello, this.page.location.origin);
+ }
+
+ private handleMessage(event: MessageEvent): void {
+ if (event.source !== this.page || event.origin !== this.page.location.origin) {
+ return;
+ }
+ // the page started listening after the hello sent on load
+ if (event.data?.type === "penpot-mcp:ready") {
+ this.sayHello();
+ return;
+ }
+ const announcement = parseAnnouncement(event.data);
+ if (announcement) {
+ this.send({ type: "session", announcement });
+ }
+ }
+
+ private send(message: ContentMessage): void {
+ try {
+ // the worker cannot be reached after the extension is reloaded or removed
+ this.worker.sendMessage(message).catch(() => {});
+ } catch {
+ // ignored for the same reason
+ }
+ }
+}
diff --git a/mcp/packages/browser-extension/src/PopupState.test.ts b/mcp/packages/browser-extension/src/PopupState.test.ts
new file mode 100644
index 0000000000..94a1b9d892
--- /dev/null
+++ b/mcp/packages/browser-extension/src/PopupState.test.ts
@@ -0,0 +1,13 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { getPopupState } from "./PopupState.ts";
+import { SessionMap } from "./SessionMap.ts";
+
+test("lists the sessions and their tabs", async () => {
+ const sessions = new SessionMap({ load: async () => undefined, save: async () => {} });
+ await sessions.announce(
+ { tabId: 7, windowId: 1 },
+ { type: "penpot-mcp:session", sessionId: "tab", status: "connected" }
+ );
+ assert.deepEqual(getPopupState(sessions), { sessions: [{ sessionId: "tab", tabId: 7 }] });
+});
diff --git a/mcp/packages/browser-extension/src/PopupState.ts b/mcp/packages/browser-extension/src/PopupState.ts
new file mode 100644
index 0000000000..1bf6678a69
--- /dev/null
+++ b/mcp/packages/browser-extension/src/PopupState.ts
@@ -0,0 +1,22 @@
+import type { SessionMap } from "./SessionMap.ts";
+
+/** Request of the popup for the extension's state. */
+export interface PopupStateRequest {
+ type: "popup:get-state";
+}
+
+/** State of the extension shown in the popup. */
+export interface PopupState {
+ sessions: { sessionId: string; tabId: number }[];
+}
+
+/**
+ * Collects the state shown in the popup.
+ *
+ * @param sessions - The map of sessions to tabs
+ */
+export function getPopupState(sessions: SessionMap): PopupState {
+ return {
+ sessions: sessions.all().map(([sessionId, entry]) => ({ sessionId, tabId: entry.tabId })),
+ };
+}
diff --git a/mcp/packages/browser-extension/src/SessionMap.test.ts b/mcp/packages/browser-extension/src/SessionMap.test.ts
new file mode 100644
index 0000000000..38e31fa874
--- /dev/null
+++ b/mcp/packages/browser-extension/src/SessionMap.test.ts
@@ -0,0 +1,125 @@
+import assert from "node:assert/strict";
+import { beforeEach, test } from "node:test";
+import type { SessionAnnouncement } from "@penpot/mcp-common";
+import { DiscardGuard } from "./DiscardGuard.ts";
+import { SessionMap, type SessionChange, type SessionEntry, type SessionStore } from "./SessionMap.ts";
+
+/** In-memory store standing in for session storage. */
+class MemoryStore implements SessionStore {
+ constructor(public saved?: Record) {}
+
+ async load() {
+ return this.saved;
+ }
+
+ async save(entries: Record) {
+ this.saved = structuredClone(entries);
+ }
+}
+
+const TAB = { tabId: 1, windowId: 10 };
+const OTHER_TAB = { tabId: 2, windowId: 10 };
+
+function announcement(
+ status: SessionAnnouncement["status"],
+ sessionId: string | null = "session"
+): SessionAnnouncement {
+ return { type: "penpot-mcp:session", sessionId, status };
+}
+
+let store: MemoryStore;
+let changes: SessionChange[];
+let sessions: SessionMap;
+
+beforeEach(() => {
+ store = new MemoryStore();
+ changes = [];
+ sessions = new SessionMap(store);
+ sessions.observe({ sessionsChanged: (change) => changes.push(change) });
+});
+
+test("maps a connected session to the announcing tab and saves it", async () => {
+ await sessions.announce(TAB, announcement("connected"));
+ assert.deepEqual(sessions.get("session"), TAB);
+ assert.deepEqual(store.saved, { session: TAB });
+ assert.deepEqual(changes, [{ tabsAdded: [1], tabsRemoved: [] }]);
+});
+
+test("ignores announcements without a connected session", async () => {
+ await sessions.announce(TAB, announcement("connecting", null));
+ await sessions.announce(TAB, announcement("connected", null));
+ await sessions.announce(TAB, announcement("error"));
+ assert.deepEqual(sessions.all(), []);
+ assert.deepEqual(changes, []);
+});
+
+test("keeps the session while the plugin reconnects", async () => {
+ await sessions.announce(TAB, announcement("connected"));
+ await sessions.announce(TAB, announcement("connecting"));
+ await sessions.announce(TAB, announcement("connected"));
+ assert.equal(sessions.get("session")?.tabId, 1);
+ assert.equal(changes.length, 1);
+});
+
+test("forgets the tab's session when its page announces a disconnect", async () => {
+ await sessions.announce(TAB, announcement("connected"));
+ await sessions.announce(TAB, announcement("disconnected", null));
+ assert.equal(sessions.get("session"), undefined);
+ assert.deepEqual(changes.at(-1), { tabsAdded: [], tabsRemoved: [1] });
+});
+
+test("a tab holds only the session it announced last", async () => {
+ await sessions.announce(TAB, announcement("connected", "old"));
+ await sessions.announce(TAB, announcement("connected", "new"));
+ assert.equal(sessions.get("old"), undefined);
+ assert.equal(sessions.get("new")?.tabId, 1);
+ assert.deepEqual(changes.at(-1), { tabsAdded: [], tabsRemoved: [] });
+});
+
+test("forgets only the sessions of a removed tab", async () => {
+ await sessions.announce(TAB, announcement("connected", "first"));
+ await sessions.announce(OTHER_TAB, announcement("connected", "second"));
+ await sessions.removeTab(1);
+ assert.deepEqual(
+ sessions.all().map(([sessionId]) => sessionId),
+ ["second"]
+ );
+ assert.deepEqual(changes.at(-1), { tabsAdded: [], tabsRemoved: [1] });
+});
+
+test("restores the saved sessions when the worker restarts", async () => {
+ await sessions.announce(TAB, announcement("connected"));
+ const restarted = new SessionMap(store);
+ await restarted.ready();
+ assert.equal(restarted.get("session")?.tabId, 1);
+});
+
+test("changes made while restoring apply to the restored sessions", async () => {
+ const saved = new MemoryStore({ first: TAB });
+ const restored = new SessionMap(saved);
+ await restored.announce(OTHER_TAB, announcement("connected", "second"));
+ assert.deepEqual(Object.keys(saved.saved!).sort(), ["first", "second"]);
+});
+
+test("the discard guard protects tabs holding a session and releases them after", async () => {
+ const calls: [number, boolean][] = [];
+ const guard = new DiscardGuard({
+ setAutoDiscardable: async (tabId, autoDiscardable) => {
+ calls.push([tabId, autoDiscardable]);
+ },
+ });
+ const guarded = new SessionMap(new MemoryStore());
+ guarded.observe(guard);
+ await guarded.announce(TAB, announcement("connected"));
+ await guarded.removeTab(1);
+ assert.deepEqual(calls, [
+ [1, false],
+ [1, true],
+ ]);
+});
+
+test("the discard guard ignores tabs closed meanwhile", async () => {
+ const guard = new DiscardGuard({ setAutoDiscardable: () => Promise.reject(new Error("No tab with id")) });
+ guard.sessionsChanged({ tabsAdded: [1], tabsRemoved: [2] });
+ await new Promise((resolve) => setImmediate(resolve));
+});
diff --git a/mcp/packages/browser-extension/src/SessionMap.ts b/mcp/packages/browser-extension/src/SessionMap.ts
new file mode 100644
index 0000000000..53eca8e1e2
--- /dev/null
+++ b/mcp/packages/browser-extension/src/SessionMap.ts
@@ -0,0 +1,129 @@
+import type { SessionAnnouncement } from "@penpot/mcp-common";
+
+/** The tab holding an MCP session. */
+export interface SessionEntry {
+ tabId: number;
+ windowId: number;
+}
+
+/** Persistence of the session map across service worker restarts. */
+export interface SessionStore {
+ load(): Promise | undefined>;
+ save(entries: Record): Promise;
+}
+
+/** Change of the tabs holding sessions, reported after each change of the map. */
+export interface SessionChange {
+ /** tabs that hold a session and held none before. */
+ tabsAdded: number[];
+ /** tabs that held a session and hold none now. */
+ tabsRemoved: number[];
+}
+
+/** Receiver of the changes of a session map. */
+export interface SessionMapObserver {
+ sessionsChanged(change: SessionChange): void;
+}
+
+/**
+ * Map from MCP session IDs to the tabs holding them.
+ *
+ * A tab holds the session it last announced as connected, until it announces a disconnect, is
+ * hidden or is closed. The map is saved after each change and restored when the service worker
+ * restarts.
+ */
+export class SessionMap {
+ private entries = new Map();
+ private readonly observers: SessionMapObserver[] = [];
+ private readonly loaded: Promise;
+
+ constructor(private readonly store: SessionStore) {
+ this.loaded = store.load().then(
+ (saved) => {
+ this.entries = new Map(Object.entries(saved ?? {}));
+ },
+ () => {}
+ );
+ }
+
+ /** Adds a receiver of the changes of this map. */
+ observe(observer: SessionMapObserver): void {
+ this.observers.push(observer);
+ }
+
+ /** Resolves once the saved entries have been restored. */
+ ready(): Promise {
+ return this.loaded;
+ }
+
+ /**
+ * Records a session announcement made by the page in a tab.
+ *
+ * @param tab - The tab and window of the announcing page
+ * @param announcement - The page's announcement
+ */
+ async announce(tab: { tabId: number; windowId: number }, announcement: SessionAnnouncement): Promise {
+ await this.loaded;
+ if (announcement.status === "disconnected") {
+ await this.change(() => this.deleteTab(tab.tabId));
+ } else if (announcement.status === "connected" && announcement.sessionId) {
+ const { sessionId } = announcement;
+ await this.change(() => {
+ // a tab holds a single session
+ this.deleteTab(tab.tabId);
+ this.entries.set(sessionId, { ...tab });
+ });
+ }
+ }
+
+ /**
+ * Forgets the sessions held by a tab.
+ *
+ * @param tabId - The tab whose page was hidden, or which was closed or replaced
+ */
+ async removeTab(tabId: number): Promise {
+ await this.loaded;
+ await this.change(() => this.deleteTab(tabId));
+ }
+
+ /** Returns the entry of a session, if a tab holds it. */
+ get(sessionId: string): SessionEntry | undefined {
+ return this.entries.get(sessionId);
+ }
+
+ /** Returns all sessions with their entries. */
+ all(): [string, SessionEntry][] {
+ return [...this.entries];
+ }
+
+ /** Applies a change, then saves the map and reports the change if any tab gained or lost a session. */
+ private async change(apply: () => void): Promise {
+ const before = this.tabs();
+ const snapshot = JSON.stringify([...this.entries]);
+ apply();
+ if (JSON.stringify([...this.entries]) === snapshot) {
+ return;
+ }
+ const after = this.tabs();
+ await this.store.save(Object.fromEntries(this.entries));
+ const change = {
+ tabsAdded: [...after].filter((tabId) => !before.has(tabId)),
+ tabsRemoved: [...before].filter((tabId) => !after.has(tabId)),
+ };
+ for (const observer of this.observers) {
+ observer.sessionsChanged(change);
+ }
+ }
+
+ private deleteTab(tabId: number): void {
+ for (const [sessionId, entry] of [...this.entries]) {
+ if (entry.tabId === tabId) {
+ this.entries.delete(sessionId);
+ }
+ }
+ }
+
+ private tabs(): Set {
+ return new Set([...this.entries.values()].map((entry) => entry.tabId));
+ }
+}
diff --git a/mcp/packages/browser-extension/src/background.ts b/mcp/packages/browser-extension/src/background.ts
new file mode 100644
index 0000000000..fc4f6ddf43
--- /dev/null
+++ b/mcp/packages/browser-extension/src/background.ts
@@ -0,0 +1,44 @@
+/**
+ * Service worker of the Penpot MCP extension.
+ *
+ * It keeps the map from MCP sessions to the tabs holding them, and keeps the browser from
+ * discarding those tabs.
+ *
+ * Chrome may stop the worker, and Firefox its event page, at any time, so listeners are
+ * registered synchronously at start and state lives in session storage.
+ */
+import { ChromeDiscardControl, ChromeSessionStore } from "./chrome.ts";
+import { DiscardGuard } from "./DiscardGuard.ts";
+import { parseAnnouncement, type ContentMessage } from "./messages.ts";
+import { getPopupState, type PopupStateRequest } from "./PopupState.ts";
+import { SessionMap } from "./SessionMap.ts";
+
+const sessions = new SessionMap(new ChromeSessionStore());
+sessions.observe(new DiscardGuard(new ChromeDiscardControl()));
+
+chrome.runtime.onMessage.addListener((message: ContentMessage | PopupStateRequest, sender, sendResponse) => {
+ if (sender.id !== chrome.runtime.id) {
+ return;
+ }
+ const tab = sender.tab;
+ if (message?.type === "popup:get-state" && !tab) {
+ void sessions.ready().then(() => sendResponse(getPopupState(sessions)));
+ // the response is sent asynchronously
+ return true;
+ }
+ if (tab?.id === undefined) {
+ return;
+ }
+ if (message?.type === "page-hidden") {
+ void sessions.removeTab(tab.id);
+ } else if (message?.type === "session") {
+ const announcement = parseAnnouncement(message.announcement);
+ if (announcement) {
+ void sessions.announce({ tabId: tab.id, windowId: tab.windowId }, announcement);
+ }
+ }
+});
+
+chrome.tabs.onRemoved.addListener((tabId) => void sessions.removeTab(tabId));
+
+chrome.tabs.onReplaced.addListener((_addedTabId, removedTabId) => void sessions.removeTab(removedTabId));
diff --git a/mcp/packages/browser-extension/src/chrome.ts b/mcp/packages/browser-extension/src/chrome.ts
new file mode 100644
index 0000000000..38954c7ced
--- /dev/null
+++ b/mcp/packages/browser-extension/src/chrome.ts
@@ -0,0 +1,26 @@
+import type { DiscardControl } from "./DiscardGuard.ts";
+import type { SessionEntry, SessionStore } from "./SessionMap.ts";
+
+const SESSIONS_KEY = "sessions";
+
+/**
+ * Session store in `chrome.storage.session`, which lives in memory, survives service worker
+ * restarts and clears when the browser closes.
+ */
+export class ChromeSessionStore implements SessionStore {
+ async load(): Promise | undefined> {
+ const stored = await chrome.storage.session.get(SESSIONS_KEY);
+ return stored[SESSIONS_KEY] as Record | undefined;
+ }
+
+ async save(entries: Record): Promise {
+ await chrome.storage.session.set({ [SESSIONS_KEY]: entries });
+ }
+}
+
+/** Discard control through the Chrome tabs API. */
+export class ChromeDiscardControl implements DiscardControl {
+ async setAutoDiscardable(tabId: number, autoDiscardable: boolean): Promise {
+ await chrome.tabs.update(tabId, { autoDiscardable });
+ }
+}
diff --git a/mcp/packages/browser-extension/src/content.ts b/mcp/packages/browser-extension/src/content.ts
new file mode 100644
index 0000000000..1e115228a4
--- /dev/null
+++ b/mcp/packages/browser-extension/src/content.ts
@@ -0,0 +1,3 @@
+import { PageBridge } from "./PageBridge.ts";
+
+new PageBridge(window, chrome.runtime).start();
diff --git a/mcp/packages/browser-extension/src/manifest.test.ts b/mcp/packages/browser-extension/src/manifest.test.ts
new file mode 100644
index 0000000000..f78006c58a
--- /dev/null
+++ b/mcp/packages/browser-extension/src/manifest.test.ts
@@ -0,0 +1,45 @@
+import assert from "node:assert/strict";
+import { access } from "node:fs/promises";
+import test from "node:test";
+import { BROWSERS, createManifest } from "../scripts/manifest.mjs";
+
+const manifests = BROWSERS.map((browser) => createManifest(browser));
+const [chrome, firefox] = manifests;
+
+test("runs the background as a module service worker in Chrome and as an event page in Firefox", () => {
+ assert.deepEqual(chrome.background, { service_worker: "background.js", type: "module" });
+ assert.deepEqual(firefox.background, { scripts: ["background.js"] });
+});
+
+test("requests only storage", () => {
+ for (const manifest of manifests) {
+ assert.equal(manifest.manifest_version, 3);
+ assert.deepEqual(manifest.permissions, ["storage"]);
+ assert.equal(manifest.optional_permissions, undefined);
+ assert.equal(manifest.host_permissions, undefined);
+ }
+});
+
+test("gives the Firefox build an add-on ID and a minimum version", () => {
+ assert.deepEqual(firefox.browser_specific_settings, {
+ gecko: {
+ id: "mcp-extension@penpot.app",
+ strict_min_version: "140.0",
+ data_collection_permissions: { required: ["none"] },
+ },
+ });
+ assert.equal(chrome.browser_specific_settings, undefined);
+});
+
+test("references icons and a popup that exist in public/", async () => {
+ for (const manifest of manifests) {
+ const paths = [
+ ...Object.values(manifest.icons as Record),
+ ...Object.values(manifest.action.default_icon as Record),
+ manifest.action.default_popup,
+ ];
+ for (const path of paths) {
+ await access(new URL(`../public/${path}`, import.meta.url));
+ }
+ }
+});
diff --git a/mcp/packages/browser-extension/src/messages.test.ts b/mcp/packages/browser-extension/src/messages.test.ts
new file mode 100644
index 0000000000..14040124e5
--- /dev/null
+++ b/mcp/packages/browser-extension/src/messages.test.ts
@@ -0,0 +1,28 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { parseAnnouncement } from "./messages.ts";
+
+const announcement = { type: "penpot-mcp:session", sessionId: "abcdefghij", status: "connected" };
+
+test("accepts session announcements, with or without a session ID", () => {
+ for (const data of [announcement, { ...announcement, sessionId: null, status: "connecting" }]) {
+ assert.deepEqual(parseAnnouncement(data), data);
+ }
+});
+
+test("keeps only the announcement fields", () => {
+ assert.deepEqual(parseAnnouncement({ ...announcement, extra: 1 }), announcement);
+});
+
+test("rejects other messages and malformed announcements", () => {
+ for (const data of [
+ null,
+ "penpot-mcp:session",
+ { ...announcement, type: "penpot-mcp-extension:hello" },
+ { ...announcement, sessionId: "" },
+ { ...announcement, sessionId: 42 },
+ { ...announcement, status: "frozen" },
+ ]) {
+ assert.equal(parseAnnouncement(data), null, JSON.stringify(data));
+ }
+});
diff --git a/mcp/packages/browser-extension/src/messages.ts b/mcp/packages/browser-extension/src/messages.ts
new file mode 100644
index 0000000000..e55644973e
--- /dev/null
+++ b/mcp/packages/browser-extension/src/messages.ts
@@ -0,0 +1,32 @@
+import type { McpConnectionStatus, SessionAnnouncement } from "@penpot/mcp-common";
+
+/**
+ * Message sent by the content script to the service worker.
+ *
+ * - `session`: the page announced its MCP session
+ * - `page-hidden`: the page is being unloaded or stored in the back/forward cache
+ */
+export type ContentMessage = { type: "session"; announcement: SessionAnnouncement } | { type: "page-hidden" };
+
+const STATUSES: readonly McpConnectionStatus[] = ["idle", "connecting", "connected", "disconnected", "error"];
+
+function isSessionId(value: unknown): value is string | null {
+ return value === null || (typeof value === "string" && value.length > 0 && value.length <= 128);
+}
+
+/**
+ * Validates a session announcement posted on the page.
+ *
+ * @param data - The data of a window message
+ * @returns The announcement, or null if the data is not a valid announcement
+ */
+export function parseAnnouncement(data: unknown): SessionAnnouncement | null {
+ if (typeof data !== "object" || data === null) {
+ return null;
+ }
+ const { type, sessionId, status } = data as Record;
+ if (type !== "penpot-mcp:session" || !isSessionId(sessionId) || !STATUSES.includes(status as McpConnectionStatus)) {
+ return null;
+ }
+ return { type, sessionId, status: status as McpConnectionStatus };
+}
diff --git a/mcp/packages/browser-extension/src/popup.ts b/mcp/packages/browser-extension/src/popup.ts
new file mode 100644
index 0000000000..fa29fac3fb
--- /dev/null
+++ b/mcp/packages/browser-extension/src/popup.ts
@@ -0,0 +1,28 @@
+import type { PopupState, PopupStateRequest } from "./PopupState.ts";
+
+const REFRESH_INTERVAL_MS = 1_000;
+
+const sessionsList = document.getElementById("sessions") as HTMLUListElement;
+
+function render(state: PopupState): void {
+ const items = state.sessions.map((session) => `${session.sessionId} (tab ${session.tabId})`);
+ sessionsList.replaceChildren(
+ ...(items.length ? items : ["No Penpot tab is connected to MCP."]).map((text) => {
+ const item = document.createElement("li");
+ item.textContent = text;
+ item.classList.toggle("empty", items.length === 0);
+ return item;
+ })
+ );
+}
+
+async function refresh(): Promise {
+ const request: PopupStateRequest = { type: "popup:get-state" };
+ const state: PopupState | undefined = await chrome.runtime.sendMessage(request);
+ if (state) {
+ render(state);
+ }
+}
+
+void refresh();
+setInterval(() => void refresh(), REFRESH_INTERVAL_MS);
diff --git a/mcp/packages/browser-extension/tsconfig.json b/mcp/packages/browser-extension/tsconfig.json
new file mode 100644
index 0000000000..b05f0b7842
--- /dev/null
+++ b/mcp/packages/browser-extension/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "lib": ["ES2022", "DOM"],
+ "types": ["chrome"],
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "exclude": ["src/*.test.ts"]
+}
diff --git a/mcp/packages/common/src/types.ts b/mcp/packages/common/src/types.ts
index f9d7cde400..5168594f54 100644
--- a/mcp/packages/common/src/types.ts
+++ b/mcp/packages/common/src/types.ts
@@ -97,3 +97,36 @@ export interface ExecuteCodeTaskResultData {
*/
log: string;
}
+
+/**
+ * Status of the Penpot MCP plugin connection, as reported to the Penpot app.
+ */
+export type McpConnectionStatus = "idle" | "connecting" | "connected" | "disconnected" | "error";
+
+/**
+ * Message posted by a browser extension's content script to ask the Penpot page for its MCP session.
+ */
+export interface ExtensionHello {
+ type: "penpot-mcp-extension:hello";
+}
+
+/**
+ * Message posted by the Penpot page when it starts listening for an {@link ExtensionHello}.
+ *
+ * A content script that said hello before the page was listening says it again.
+ */
+export interface PageReady {
+ type: "penpot-mcp:ready";
+}
+
+/**
+ * Message posted by the Penpot page to announce its MCP session to a browser extension.
+ *
+ * The page sends it in reply to {@link ExtensionHello} and on every connection status change.
+ */
+export interface SessionAnnouncement {
+ type: "penpot-mcp:session";
+ /** the MCP session ID of the page, or null while it is not known. */
+ sessionId: string | null;
+ status: McpConnectionStatus;
+}
diff --git a/mcp/packages/plugin/src/ConnectionMetadata.test.ts b/mcp/packages/plugin/src/ConnectionMetadata.test.ts
index 3131086153..1ae436afd6 100644
--- a/mcp/packages/plugin/src/ConnectionMetadata.test.ts
+++ b/mcp/packages/plugin/src/ConnectionMetadata.test.ts
@@ -147,6 +147,17 @@ test("resume and freeze events cannot send liveness messages before initializati
assert.deepEqual(ui.sockets[0].sent, [{ type: "heartbeat" }]);
});
+test("answers heartbeat requests once initialized, without forwarding them to the plugin", () => {
+ const ui = pluginUi();
+ ui.connect();
+ ui.sockets[0].receive({ type: "heartbeat-request" });
+ assert.deepEqual(ui.sockets[0].sent, []);
+ ui.sockets[0].receive({ type: "initialized" });
+ ui.sockets[0].receive({ type: "heartbeat-request" });
+ assert.deepEqual(ui.sockets[0].sent, [{ type: "heartbeat" }]);
+ assert.equal(ui.messages.filter((message) => message.type === "heartbeat-request").length, 0);
+});
+
test("plugin reads the current file for each connection metadata request", () => {
let onMessage!: (message: any) => void;
const messages: any[] = [];
diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts
index 71973ade0b..8ae1163ad6 100644
--- a/mcp/packages/plugin/src/main.ts
+++ b/mcp/packages/plugin/src/main.ts
@@ -145,7 +145,9 @@ 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.
+ * page JavaScript is frozen and unable to run MCP tasks. The plugin sends one on a timer and
+ * in answer to each heartbeat request of the server, since a browser runs the timers of a
+ * hidden page as rarely as once a minute but handles socket messages at once.
*/
function sendHeartbeat(): boolean {
if (connectionReady && ws?.readyState === WebSocket.OPEN) {
@@ -252,6 +254,10 @@ function connectToMcpServer(baseUrl?: string, token?: string): void {
parent.postMessage({ type: "context-request" }, "*");
return;
}
+ if (request.type === "heartbeat-request") {
+ sendHeartbeat();
+ return;
+ }
// Track the current task received from the MCP server
if (request.task) {
updateCurrentTask(request.task);
diff --git a/mcp/packages/server/src/PenpotConnection.ts b/mcp/packages/server/src/PenpotConnection.ts
index 267c1b159b..902f248a48 100644
--- a/mcp/packages/server/src/PenpotConnection.ts
+++ b/mcp/packages/server/src/PenpotConnection.ts
@@ -17,6 +17,8 @@ export interface PenpotConnection extends PluginLivenessState {
readonly userToken: string | null;
readonly session: PenpotSession;
readonly pingInterval: NodeJS.Timeout;
+ /** the interval sending heartbeat requests to the plugin. */
+ readonly heartbeatRequestInterval: NodeJS.Timeout;
/** whether initialization and dispatch subscriptions have completed. */
ready: boolean;
}
diff --git a/mcp/packages/server/src/PluginBridge.sessions.test.ts b/mcp/packages/server/src/PluginBridge.sessions.test.ts
index 1a42241169..3e676725f2 100644
--- a/mcp/packages/server/src/PluginBridge.sessions.test.ts
+++ b/mcp/packages/server/src/PluginBridge.sessions.test.ts
@@ -1,8 +1,8 @@
import assert from "node:assert/strict";
import { once } from "node:events";
-import { afterEach, beforeEach, test } from "node:test";
+import { afterEach, beforeEach, mock, test } from "node:test";
import { WebSocket } from "ws";
-import { PluginBridge } from "./PluginBridge";
+import { HEARTBEAT_REQUEST_INTERVAL_MS, PluginBridge } from "./PluginBridge";
import { PluginTask } from "./PluginTask";
import type { PenpotMcpServer } from "./PenpotMcpServer";
@@ -37,17 +37,33 @@ async function connect(sessionId: string, token = "alice", fileName = "Design",
const socket = new WebSocket(`ws://127.0.0.1:${port}?userToken=${token}`);
sockets.push(socket);
await once(socket, "open");
+ // answer tasks from the start, since one may arrive right behind the initialization
+ socket.on("message", (raw) => {
+ const request = JSON.parse(raw.toString());
+ if (request.type !== "initialized" && request.type !== "heartbeat-request") {
+ socket.send(JSON.stringify({ id: request.id, success: true, data: reply }));
+ }
+ });
const initialized = once(socket, "message", { signal: AbortSignal.timeout(1500) });
socket.send(JSON.stringify({ type: "initialize", session: { sessionId, fileId: "file-1", fileName } }));
const [message] = await initialized;
assert.equal(JSON.parse(message.toString()).type, "initialized");
- socket.on("message", (raw) => {
- const request = JSON.parse(raw.toString());
- socket.send(JSON.stringify({ id: request.id, success: true, data: reply }));
- });
return socket;
}
+test("asks each plugin for a heartbeat at a fixed interval", async () => {
+ mock.timers.enable({ apis: ["setInterval"] });
+ try {
+ const socket = await connect("tab-1");
+ const requested = once(socket, "message", { signal: AbortSignal.timeout(1500) });
+ mock.timers.tick(HEARTBEAT_REQUEST_INTERVAL_MS);
+ const [message] = await requested;
+ assert.deepEqual(JSON.parse(message.toString()), { type: "heartbeat-request" });
+ } finally {
+ mock.timers.reset();
+ }
+});
+
test("keeps two tabs for the same file and dispatches to the selected session", async () => {
await connect("tab-1");
await connect("tab-2");
@@ -169,3 +185,21 @@ test("a dashboard session remains routable while its workspace metadata changes"
assert.deepEqual(bridge.getUserConnections("alice")!.getSessions(), [{ sessionId: "tab", ...context }]);
}
});
+
+test("a task fails at once when its plugin connection closes before answering", async () => {
+ const plugin = new WebSocket(`ws://127.0.0.1:${port}?userToken=alice`);
+ sockets.push(plugin);
+ await once(plugin, "open");
+ const initialized = once(plugin, "message");
+ plugin.send(JSON.stringify({ type: "initialize", session: { sessionId: "tab", fileId: null, fileName: null } }));
+ await initialized;
+ // the socket closes once the task reaches it
+ plugin.on("message", () => plugin.terminate());
+ const started = Date.now();
+ await assert.rejects(
+ bridge.executePluginTask(new PluginTask("test", {}), "tab"),
+ /connection closed before the task finished/
+ );
+ // well within the 1s task timeout of these tests
+ assert.ok(Date.now() - started < 500);
+});
diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts
index 913b28b26a..0c1136deea 100644
--- a/mcp/packages/server/src/PluginBridge.ts
+++ b/mcp/packages/server/src/PluginBridge.ts
@@ -15,6 +15,14 @@ import { MultiInstanceTaskDispatcher } from "./dispatch/MultiInstanceTaskDispatc
const KEEP_ALIVE_TIME = 30000; // 30 seconds
const INITIALIZATION_TIMEOUT_MS = 10_000;
+/**
+ * The interval, in ms, between the heartbeat requests sent to each plugin.
+ *
+ * A browser runs the timers of a hidden page as rarely as once a minute, which would leave the
+ * plugin's own heartbeat older than the stale threshold, but it handles socket messages at once.
+ */
+export const HEARTBEAT_REQUEST_INTERVAL_MS = 10_000;
+
/**
* Maximum plugin heartbeat age before a connection is stale.
*
@@ -187,8 +195,8 @@ export class PluginBridge implements TaskDispatchHost {
}
});
- ws.on("close", () => {
- this.logger.info("WebSocket connection closed");
+ ws.on("close", (code: number, reason: Buffer) => {
+ this.logger.info("WebSocket connection closed (code %d%s)", code, reason.length ? `: ${reason}` : "");
clearTimeout(initializationTimeout);
this.removeConnection(ws);
});
@@ -218,12 +226,17 @@ export class PluginBridge implements TaskDispatchHost {
const pingInterval = setInterval(() => {
ws.ping();
}, KEEP_ALIVE_TIME);
+ // the plugin answers with a heartbeat, which a frozen page cannot send
+ const heartbeatRequestInterval = setInterval(() => {
+ ws.send(JSON.stringify({ type: "heartbeat-request" }));
+ }, HEARTBEAT_REQUEST_INTERVAL_MS);
// register the client connection with both indexes
const connection: PenpotConnection = {
socket: ws,
userToken,
pingInterval,
+ heartbeatRequestInterval,
session,
ready: false,
lastHeartbeat: Date.now(),
@@ -260,10 +273,11 @@ export class PluginBridge implements TaskDispatchHost {
/**
* Removes a client connection and releases all resources associated with it.
*
- * Clears the per-connection keep-alive interval and removes the connection from the
- * socket-keyed index and the user's session collection. The token-keyed index entry
- * is removed only when the user's last local connection closes. Safe to call with a
- * socket that is not (or no longer) registered.
+ * Clears the per-connection keep-alive and heartbeat-request intervals and removes the
+ * connection from the socket-keyed index and the user's session collection. The
+ * token-keyed index entry is removed only when the user's last local connection closes.
+ * Tasks sent over the connection and still awaiting a response fail at once. Safe to call
+ * with a socket that is not (or no longer) registered.
*
* @param ws - The WebSocket whose connection state should be removed
*/
@@ -274,9 +288,17 @@ export class PluginBridge implements TaskDispatchHost {
}
connection.ready = false;
clearInterval(connection.pingInterval);
+ clearInterval(connection.heartbeatRequestInterval);
this.connectionsBySocket.delete(ws);
const connections = this.getUserConnections(connection.userToken)!;
connections.remove(connection);
+ // a task sent over the closed socket can no longer be answered, since responses only count
+ // from the connection the task went to
+ for (const [taskId, taskConnection] of [...this.taskConnections]) {
+ if (taskConnection === connection) {
+ this.rejectPendingTask(taskId, new Error("The plugin connection closed before the task finished."));
+ }
+ }
if (connection.userToken !== null && connections.size === 0) {
this.userPenpotConnectionsByToken.delete(connection.userToken);
}
diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml
index 3b7d86bd01..64b7eae00a 100644
--- a/mcp/pnpm-lock.yaml
+++ b/mcp/pnpm-lock.yaml
@@ -177,6 +177,24 @@ importers:
specifier: ^3.9.10
version: 3.9.10
+ packages/browser-extension:
+ devDependencies:
+ '@penpot/mcp-common':
+ specifier: workspace:../common
+ version: link:../common
+ '@types/chrome':
+ specifier: ^0.3.6
+ version: 0.3.6
+ esbuild:
+ specifier: ^0.28.2
+ version: 0.28.2
+ tsx:
+ specifier: ^4.23.15
+ version: 4.23.15
+ typescript:
+ specifier: ^6.0.3
+ version: 6.0.3
+
packages/common:
devDependencies:
typescript:
@@ -783,6 +801,9 @@ packages:
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
+ '@types/chrome@0.3.6':
+ resolution: {integrity: sha512-NXCrCBefCHCKMVbkaxfBQqyP766Wg85S2FQhixCCxq7AtjJwoEaja2y5NSqCzpihIi9mPqhaF5RuybTnrdYD2Q==}
+
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
@@ -792,6 +813,9 @@ packages:
'@types/express@5.0.6':
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
+ '@types/har-format@1.2.16':
+ resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==}
+
'@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
@@ -1918,6 +1942,10 @@ snapshots:
'@types/connect': 3.4.38
'@types/node': 26.6.5
+ '@types/chrome@0.3.6':
+ dependencies:
+ '@types/har-format': 1.2.16
+
'@types/connect@3.4.38':
dependencies:
'@types/node': 26.6.5
@@ -1935,6 +1963,8 @@ snapshots:
'@types/express-serve-static-core': 5.1.3
'@types/serve-static': 2.2.0
+ '@types/har-format@1.2.16': {}
+
'@types/http-errors@2.0.5': {}
'@types/js-yaml@4.0.9': {}
diff --git a/mcp/pnpm-workspace.yaml b/mcp/pnpm-workspace.yaml
index 3da100dca6..5aa8300f88 100644
--- a/mcp/pnpm-workspace.yaml
+++ b/mcp/pnpm-workspace.yaml
@@ -19,6 +19,7 @@ packages:
- "packages/common"
- "packages/server"
- "packages/plugin"
+ - "packages/browser-extension"
overrides:
'@hono/node-server@<2.0.5': ^2.0.5
diff --git a/mcp/scripts/build b/mcp/scripts/build
index 3e5337fda2..671091ac9f 100755
--- a/mcp/scripts/build
+++ b/mcp/scripts/build
@@ -21,7 +21,7 @@ set +e
set -e
popd
-pnpm -r --filter "!mcp-plugin" install;
+pnpm -r --filter "!mcp-plugin" --filter "!mcp-browser-extension" install;
pnpm -r --filter "mcp-server" run build;
rsync -avr packages/server/dist/ ./dist/;