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/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/;