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