From c13fb65e99d760bdcb56f68fdf0d7d472bb9acac Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Mon, 26 Oct 2026 10:32:24 +0000 Subject: [PATCH] :sparkles: Build the MCP browser extension for Firefox Build one extension per browser, in dist/chrome and dist/firefox, from the same source. A script makes each manifest. The code keeps the chrome namespace, which Firefox implements with promises in Manifest V3. The Firefox manifest runs the background as an event page, leaves out the debugger permission, and sets the add-on ID and Firefox 140 as the minimum version. Its extension page policy drops the default upgrade-insecure-requests, which would turn ws:// servers into wss://. Firefox has no debugger API, and it does not freeze background pages: it only slows their timers, which the plugin's heartbeat survives. The background therefore unfreezes in place only where the debugger API exists, and otherwise brings the tab forward and goes back, as a fallback that should rarely run. Firefox stops an event page that is idle for 30 s, even while its WebSocket is open, so the wake agent connection would drop and come back with each alarm. While it holds a connection, the background calls an extension API every 20 s, which Firefox counts as activity. --- mcp/packages/browser-extension/README.md | 43 +++++++----- .../browser-extension/public/manifest.json | 23 ------- .../browser-extension/scripts/build.mjs | 21 ++++-- .../browser-extension/scripts/manifest.mjs | 56 ++++++++++++++++ .../browser-extension/src/WakeStrategies.ts | 3 +- .../browser-extension/src/background.ts | 17 ++++- .../browser-extension/src/manifest.test.ts | 66 ++++++++++++++----- 7 files changed, 161 insertions(+), 68 deletions(-) delete mode 100644 mcp/packages/browser-extension/public/manifest.json create mode 100644 mcp/packages/browser-extension/scripts/manifest.mjs diff --git a/mcp/packages/browser-extension/README.md b/mcp/packages/browser-extension/README.md index b476b11656..c9d4a0943d 100644 --- a/mcp/packages/browser-extension/README.md +++ b/mcp/packages/browser-extension/README.md @@ -1,6 +1,6 @@ # Penpot MCP Wake -A Chrome extension that wakes a background Penpot tab when an MCP task needs it. +A Chrome and Firefox extension that wakes a background Penpot tab when an MCP task needs it. Chrome freezes background tabs, and a frozen tab cannot run the MCP plugin. When a task targets a frozen session, the MCP server asks this extension to activate the tab, waits @@ -10,22 +10,27 @@ for the plugin to answer again, and then runs the task. - The content script asks the Penpot page for its MCP session. The page answers with the session ID, MCP server URL, token and connection status. -- The service worker maps each session to its tab and opens one WebSocket per MCP server - and token, on the endpoint the plugin uses. It registers there as a wake agent. -- When the server sends a wake request, the worker wakes the tab as described below. +- The background (a service worker in Chrome, an event page in Firefox) maps each session to + its tab and opens one WebSocket per MCP server and token, on the endpoint the plugin uses. + It registers there as a wake agent. +- When the server sends a wake request, the background wakes the tab as described below. The popup turns waking on or off and lists the paired sessions and server connections. ## How a tab is woken -- The extension unfreezes the page in place: it attaches the debugger to the tab, sets the - page lifecycle state to active, and detaches, without switching tabs. Chrome shows a - debugging notice while the extension is attached. Chrome does not allow `debugger` as an - optional permission, so the extension requires it, and Chrome warns about it at install. -- **Fallback**, when the debugger fails (for instance with DevTools open on the tab): the - extension brings the tab forward, waits until the page reports that it runs again, then - returns to the tab and window that had the focus. Whether the window comes forward depends - on the window manager. +- **Chrome** unfreezes the page in place: the extension attaches the debugger to the tab, + sets the page lifecycle state to active, and detaches, without switching tabs. Chrome + shows a debugging notice while the extension is attached. Chrome does not allow + `debugger` as an optional permission, so the extension requires it, and Chrome warns + about it at install. +- **Firefox** has no debugger API, but it does not freeze background pages either: it only + slows their timers, which the plugin's heartbeat survives. The extension keeps tabs that + hold a session from being unloaded. +- **Fallback**, when the debugger fails in Chrome (for instance with DevTools open on the + tab) and always in Firefox: the extension brings the tab forward, waits until the page + reports that it runs again, then returns to the tab and window that had the focus. + Whether the window comes forward depends on the window manager. ## Build and load @@ -37,13 +42,19 @@ pnpm --filter mcp-common run build pnpm --filter mcp-browser-extension run build ``` -Then open `chrome://extensions`, turn on developer mode, choose "Load unpacked" and pick -`mcp/packages/browser-extension/dist`. Reload Penpot tabs opened before the extension was -loaded, so the content script runs in them. +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` and on `localhost` at any port. -The service worker does not use the exception you grant a page for an untrusted +In Chrome, the service worker does not use the exception you grant a page for an untrusted certificate. With the development environment's self-signed certificate it fails with `net::ERR_CERT_AUTHORITY_INVALID` until Chrome trusts the certificate. On Linux, copy it out of the devenv's main container and add it to Chrome's certificate store: diff --git a/mcp/packages/browser-extension/public/manifest.json b/mcp/packages/browser-extension/public/manifest.json deleted file mode 100644 index c7377dddb4..0000000000 --- a/mcp/packages/browser-extension/public/manifest.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "manifest_version": 3, - "name": "Penpot MCP Wake", - "version": "0.1.0", - "description": "Wakes a background Penpot tab when an MCP task needs it.", - "minimum_chrome_version": "116", - "permissions": ["storage", "alarms", "debugger"], - "action": { - "default_title": "Penpot MCP Wake", - "default_popup": "popup.html" - }, - "background": { - "service_worker": "background.js", - "type": "module" - }, - "content_scripts": [ - { - "matches": ["https://design.penpot.app/*", "https://localhost/*", "http://localhost/*"], - "js": ["content.js"], - "run_at": "document_idle" - } - ] -} diff --git a/mcp/packages/browser-extension/scripts/build.mjs b/mcp/packages/browser-extension/scripts/build.mjs index 84aeb6af9b..6d7b6b3069 100644 --- a/mcp/packages/browser-extension/scripts/build.mjs +++ b/mcp/packages/browser-extension/scripts/build.mjs @@ -1,11 +1,18 @@ import { build } from "esbuild"; -import { cp, rm } from "node:fs/promises"; +import { cp, rm, writeFile } from "node:fs/promises"; +import { BROWSERS, createManifest } from "./manifest.mjs"; -const options = { bundle: true, target: "chrome116", outdir: "dist", logLevel: "info" }; +const TARGETS = { chrome: "chrome116", firefox: "firefox140" }; await rm("dist", { recursive: true, force: true }); -// the service worker and the popup load as modules -await build({ ...options, entryPoints: ["src/background.ts", "src/popup.ts"], format: "esm" }); -// content scripts load as classic scripts -await build({ ...options, entryPoints: ["src/content.ts"], format: "iife" }); -await cp("public", "dist", { recursive: 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..6fab3ff8bd --- /dev/null +++ b/mcp/packages/browser-extension/scripts/manifest.mjs @@ -0,0 +1,56 @@ +/** Browsers the extension is built for, one directory of `dist/` each. */ +export const BROWSERS = ["chrome", "firefox"]; + +const CONTENT_SCRIPTS = [ + { + matches: ["https://design.penpot.app/*", "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 and needs `debugger` to unfreeze pages + * in place. Firefox runs it as an event page, has no debugger API 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 Wake", + version: "0.1.0", + description: "Wakes a background Penpot tab when an MCP task needs it.", + action: { + default_title: "Penpot MCP Wake", + default_popup: "popup.html", + }, + content_scripts: CONTENT_SCRIPTS, + }; + if (browser === "chrome") { + return { + ...common, + minimum_chrome_version: "116", + // Chrome does not allow debugger as an optional permission + permissions: ["storage", "alarms", "debugger"], + background: { service_worker: "background.js", type: "module" }, + }; + } + return { + ...common, + permissions: ["storage", "alarms"], + background: { scripts: ["background.js"] }, + // the default policy adds upgrade-insecure-requests, which breaks ws:// servers + content_security_policy: { extension_pages: "script-src 'self'" }, + browser_specific_settings: { + gecko: { + id: "mcp-wake@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/WakeStrategies.ts b/mcp/packages/browser-extension/src/WakeStrategies.ts index b4f1e64147..8365af4087 100644 --- a/mcp/packages/browser-extension/src/WakeStrategies.ts +++ b/mcp/packages/browser-extension/src/WakeStrategies.ts @@ -95,7 +95,8 @@ export class ActivateAndReturnStrategy implements WakeStrategy { /** * Makes the page active through the debugger, without switching tabs, and falls back to another - * strategy when that fails, for instance when DevTools is open on the tab. + * strategy when that fails, for instance when DevTools is open on the tab. Chrome only, since + * Firefox has no debugger API. */ export class LifecycleStrategy implements WakeStrategy { constructor( diff --git a/mcp/packages/browser-extension/src/background.ts b/mcp/packages/browser-extension/src/background.ts index 8c56b03f32..a7a6b4165b 100644 --- a/mcp/packages/browser-extension/src/background.ts +++ b/mcp/packages/browser-extension/src/background.ts @@ -4,8 +4,8 @@ * It keeps the map from MCP sessions to the tabs holding them, holds a connection to each MCP * server those sessions use, and activates a session's tab when the server asks it to. * - * Chrome may stop the worker at any time, so listeners are registered synchronously at start - * and state lives in session storage. + * 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, @@ -24,14 +24,17 @@ import { WakeAgentConnections } from "./WakeAgentConnections.ts"; import { ActivateAndReturnStrategy, LifecycleStrategy, ResumeSignals } from "./WakeStrategies.ts"; const RESTART_ALARM = "restart-connections"; +const KEEP_ALIVE_MS = 20_000; const sessions = new SessionMap(new ChromeSessionStore()); const settings = new ChromeWakeSettings(); const resumes = new ResumeSignals(); +const activateAndReturn = new ActivateAndReturnStrategy(new ChromeTabControl(), resumes); +// Chrome unfreezes the page in place; Firefox has no debugger API and does not freeze pages const waker = new TabWaker( sessions, settings, - new LifecycleStrategy(new ChromeLifecycleControl(), new ActivateAndReturnStrategy(new ChromeTabControl(), resumes)) + chrome.debugger ? new LifecycleStrategy(new ChromeLifecycleControl(), activateAndReturn) : activateAndReturn ); const connections = new WakeAgentConnections(sessions, getAgentId(), (url) => new WebSocket(url), waker); sessions.observe(new DiscardGuard(new ChromeDiscardControl())); @@ -76,4 +79,12 @@ chrome.alarms.onAlarm.addListener((alarm) => { }); void chrome.alarms.create(RESTART_ALARM, { periodInMinutes: 0.5 }); +// Firefox stops an event page idle for 30s even while its WebSockets are open, and an extension +// API call counts as activity +setInterval(() => { + if (connections.summaries().length > 0) { + void chrome.runtime.getPlatformInfo(); + } +}, KEEP_ALIVE_MS); + void connections.sync(); diff --git a/mcp/packages/browser-extension/src/manifest.test.ts b/mcp/packages/browser-extension/src/manifest.test.ts index b2abe144f1..82f2c04fc3 100644 --- a/mcp/packages/browser-extension/src/manifest.test.ts +++ b/mcp/packages/browser-extension/src/manifest.test.ts @@ -1,31 +1,61 @@ import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; import test from "node:test"; +import { BROWSERS, createManifest } from "../scripts/manifest.mjs"; -const manifest = JSON.parse(await readFile(new URL("../public/manifest.json", import.meta.url), "utf8")); +const chrome = createManifest("chrome"); +const firefox = createManifest("firefox"); -test("is a Manifest V3 extension whose service worker loads as a module", () => { - assert.equal(manifest.manifest_version, 3); - assert.deepEqual(manifest.background, { service_worker: "background.js", type: "module" }); +test("builds a Manifest V3 extension for Chrome and Firefox", () => { + assert.deepEqual(BROWSERS, ["chrome", "firefox"]); + assert.equal(chrome.manifest_version, 3); + assert.equal(firefox.manifest_version, 3); }); -test("requests storage, alarms and, to unfreeze pages in place, debugger", () => { - // Chrome does not allow debugger as an optional permission - assert.deepEqual(manifest.permissions, ["storage", "alarms", "debugger"]); - assert.equal(manifest.optional_permissions, undefined); - assert.equal(manifest.host_permissions, undefined); +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 storage, alarms and, in Chrome to unfreeze pages in place, debugger", () => { + // Chrome does not allow debugger as an optional permission; Firefox has no debugger API + assert.deepEqual(chrome.permissions, ["storage", "alarms", "debugger"]); + assert.deepEqual(firefox.permissions, ["storage", "alarms"]); + for (const manifest of [chrome, firefox]) { + 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-wake@penpot.app", + strict_min_version: "140.0", + data_collection_permissions: { required: ["none"] }, + }, + }); + assert.equal(chrome.browser_specific_settings, undefined); +}); + +test("lets the Firefox build reach ws:// servers, as Chrome does", () => { + assert.deepEqual(firefox.content_security_policy, { extension_pages: "script-src 'self'" }); + assert.equal(chrome.content_security_policy, undefined); }); test("runs the content script on Penpot and on local development instances", () => { - assert.deepEqual(manifest.content_scripts, [ - { - matches: ["https://design.penpot.app/*", "https://localhost/*", "http://localhost/*"], - js: ["content.js"], - run_at: "document_idle", - }, - ]); + for (const manifest of [chrome, firefox]) { + assert.deepEqual(manifest.content_scripts, [ + { + matches: ["https://design.penpot.app/*", "https://localhost/*", "http://localhost/*"], + js: ["content.js"], + run_at: "document_idle", + }, + ]); + } }); test("opens the popup from the toolbar button", () => { - assert.deepEqual(manifest.action, { default_title: "Penpot MCP Wake", default_popup: "popup.html" }); + for (const manifest of [chrome, firefox]) { + assert.deepEqual(manifest.action, { default_title: "Penpot MCP Wake", default_popup: "popup.html" }); + } });