✨ Add a popup to the MCP browser extension

The extension's toolbar popup lets the user turn waking on or off and
lists the paired sessions and the state of each MCP server connection.
It reads that state from the service worker, which leaves out the user
tokens, and refreshes it every second while open.

A README explains how the extension works and how to build and load it,
including how to make Chrome trust the development environment's
self-signed certificate, which the service worker needs.
This commit is contained in:
alonso.torres 2026-10-26 10:30:16 +00:00
parent 86649bf59d
commit f6d061cfef
10 changed files with 267 additions and 4 deletions

View File

@ -0,0 +1,46 @@
# Penpot MCP Wake
A Chrome 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
for the plugin to answer again, and then runs the task.
## How it works
- 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 activates the tab and focuses its
window.
The popup turns waking on or off and lists the paired sessions and server connections.
## Build and load
From `mcp/`:
```sh
pnpm install
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 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
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:
```sh
docker cp <main-container>:/home/selfsigned.crt /tmp/penpot-devenv.crt
certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n "Penpot devenv localhost" -i /tmp/penpot-devenv.crt
```
Restart Chrome afterwards.

View File

@ -5,6 +5,10 @@
"description": "Wakes a background Penpot tab when an MCP task needs it.",
"minimum_chrome_version": "116",
"permissions": ["storage", "alarms"],
"action": {
"default_title": "Penpot MCP Wake",
"default_popup": "popup.html"
},
"background": {
"service_worker": "background.js",
"type": "module"

View File

@ -0,0 +1,55 @@
: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);
}
.switch {
display: flex;
gap: 8px;
align-items: center;
}
ul {
margin: 0;
padding-left: 18px;
overflow-wrap: anywhere;
}
li.empty {
list-style: none;
margin-left: -18px;
color: var(--muted);
}

View File

@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Penpot MCP Wake</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<h1>Penpot MCP Wake</h1>
<label class="switch">
<input id="enabled" type="checkbox" />
Wake Penpot tabs for MCP tasks
</label>
<h2>Paired sessions</h2>
<ul id="sessions"></ul>
<h2>MCP servers</h2>
<ul id="connections"></ul>
<script type="module" src="popup.js"></script>
</body>
</html>

View File

@ -5,7 +5,7 @@ const options = { bundle: true, target: "chrome116", outdir: "dist", logLevel: "
await rm("dist", { recursive: true, force: true });
// the service worker and the popup load as modules
await build({ ...options, entryPoints: ["src/background.ts"], format: "esm" });
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 });

View File

@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getPopupState } from "./PopupState.ts";
import { SessionMap } from "./SessionMap.ts";
test("lists the paired sessions and connections without the user tokens", async () => {
const sessions = new SessionMap({ load: async () => undefined, save: async () => {} });
await sessions.announce(
{ tabId: 7, windowId: 1 },
{
type: "penpot-mcp:session",
sessionId: "tab",
serverUrl: "wss://penpot/mcp/ws",
token: "secret",
status: "connected",
}
);
const connections = [{ serverUrl: "wss://penpot/mcp/ws", status: "connected" as const, sessionIds: ["tab"] }];
const state = getPopupState(false, sessions, connections);
assert.deepEqual(state, {
enabled: false,
sessions: [{ sessionId: "tab", tabId: 7, serverUrl: "wss://penpot/mcp/ws" }],
connections,
});
assert.doesNotMatch(JSON.stringify(state), /secret/);
});

View File

@ -0,0 +1,33 @@
import type { SessionMap } from "./SessionMap.ts";
import type { ConnectionSummary } from "./WakeAgentConnections.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 {
enabled: boolean;
sessions: { sessionId: string; tabId: number; serverUrl: string }[];
connections: ConnectionSummary[];
}
/**
* Collects the state shown in the popup. It leaves out the user tokens.
*
* @param enabled - Whether waking is turned on
* @param sessions - The map of sessions to tabs
* @param connections - The summaries of the server connections
*/
export function getPopupState(enabled: boolean, sessions: SessionMap, connections: ConnectionSummary[]): PopupState {
return {
enabled,
sessions: sessions.all().map(([sessionId, entry]) => ({
sessionId,
tabId: entry.tabId,
serverUrl: entry.serverUrl,
})),
connections,
};
}

View File

@ -10,6 +10,7 @@
import { ChromeDiscardControl, ChromeSessionStore, ChromeTabControl, ChromeWakeSwitch, getAgentId } 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";
import { TabWaker } from "./TabWaker.ts";
import { WakeAgentConnections } from "./WakeAgentConnections.ts";
@ -17,14 +18,25 @@ import { WakeAgentConnections } from "./WakeAgentConnections.ts";
const RESTART_ALARM = "restart-connections";
const sessions = new SessionMap(new ChromeSessionStore());
const waker = new TabWaker(sessions, new ChromeTabControl(), new ChromeWakeSwitch());
const wakeSwitch = new ChromeWakeSwitch();
const waker = new TabWaker(sessions, new ChromeTabControl(), wakeSwitch);
const connections = new WakeAgentConnections(sessions, getAgentId(), (url) => new WebSocket(url), waker);
sessions.observe(new DiscardGuard(new ChromeDiscardControl()));
sessions.observe(connections);
chrome.runtime.onMessage.addListener((message: ContentMessage, sender) => {
chrome.runtime.onMessage.addListener((message: ContentMessage | PopupStateRequest, sender, sendResponse) => {
if (sender.id !== chrome.runtime.id) {
return;
}
const tab = sender.tab;
if (tab?.id === undefined || sender.id !== chrome.runtime.id) {
if (message?.type === "popup:get-state" && !tab) {
void Promise.all([wakeSwitch.isEnabled(), sessions.ready()]).then(([enabled]) =>
sendResponse(getPopupState(enabled, sessions, connections.summaries()))
);
// the response is sent asynchronously
return true;
}
if (tab?.id === undefined) {
return;
}
if (message?.type === "page-hidden") {

View File

@ -23,3 +23,7 @@ test("runs the content script on Penpot and on local development instances", ()
},
]);
});
test("opens the popup from the toolbar button", () => {
assert.deepEqual(manifest.action, { default_title: "Penpot MCP Wake", default_popup: "popup.html" });
});

View File

@ -0,0 +1,63 @@
import { ChromeWakeSwitch } from "./chrome.ts";
import type { PopupState, PopupStateRequest } from "./PopupState.ts";
const REFRESH_INTERVAL_MS = 1_000;
const STATUS_LABELS: Record<string, string> = {
connecting: "Connecting",
connected: "Connected",
disconnected: "Reconnecting",
rejected: "Rejected by the server",
closed: "Closed",
};
const wakeSwitch = new ChromeWakeSwitch();
const enabledInput = document.getElementById("enabled") as HTMLInputElement;
const sessionsList = document.getElementById("sessions") as HTMLUListElement;
const connectionsList = document.getElementById("connections") as HTMLUListElement;
/** Replaces the items of a list, showing the placeholder when there are none. */
function renderList(list: HTMLUListElement, items: string[], placeholder: string): void {
list.replaceChildren(
...(items.length ? items : [placeholder]).map((text) => {
const item = document.createElement("li");
item.textContent = text;
item.classList.toggle("empty", items.length === 0);
return item;
})
);
}
function render(state: PopupState): void {
enabledInput.checked = state.enabled;
renderList(
sessionsList,
state.sessions.map((session) => `${session.sessionId} (tab ${session.tabId})`),
"No Penpot tab is connected to MCP."
);
renderList(
connectionsList,
state.connections.map(
(connection) =>
`${connection.serverUrl}: ${STATUS_LABELS[connection.status] ?? connection.status}, ` +
`${connection.sessionIds.length} session(s)`
),
"No MCP server connection."
);
}
async function refresh(): Promise<void> {
const request: PopupStateRequest = { type: "popup:get-state" };
const state: PopupState | undefined = await chrome.runtime.sendMessage(request);
if (state) {
render(state);
}
}
enabledInput.addEventListener("change", async () => {
await wakeSwitch.setEnabled(enabledInput.checked);
await refresh();
});
void refresh();
setInterval(() => void refresh(), REFRESH_INTERVAL_MS);