✨ 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.
This commit is contained in:
alonso.torres 2026-10-26 10:32:24 +00:00
parent f9c5763345
commit c13fb65e99
7 changed files with 161 additions and 68 deletions

View File

@ -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:

View File

@ -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"
}
]
}

View File

@ -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");
}

View File

@ -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"] },
},
},
};
}

View File

@ -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(

View File

@ -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();

View File

@ -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" });
}
});