From f6d061cfef3b84ba27d1d1e7d7160c828a4bc101 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Mon, 26 Oct 2026 10:30:16 +0000 Subject: [PATCH] :sparkles: 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. --- mcp/packages/browser-extension/README.md | 46 ++++++++++++++ .../browser-extension/public/manifest.json | 4 ++ .../browser-extension/public/popup.css | 55 ++++++++++++++++ .../browser-extension/public/popup.html | 20 ++++++ .../browser-extension/scripts/build.mjs | 2 +- .../browser-extension/src/PopupState.test.ts | 26 ++++++++ .../browser-extension/src/PopupState.ts | 33 ++++++++++ .../browser-extension/src/background.ts | 18 +++++- .../browser-extension/src/manifest.test.ts | 4 ++ mcp/packages/browser-extension/src/popup.ts | 63 +++++++++++++++++++ 10 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 mcp/packages/browser-extension/README.md create mode 100644 mcp/packages/browser-extension/public/popup.css create mode 100644 mcp/packages/browser-extension/public/popup.html create mode 100644 mcp/packages/browser-extension/src/PopupState.test.ts create mode 100644 mcp/packages/browser-extension/src/PopupState.ts create mode 100644 mcp/packages/browser-extension/src/popup.ts diff --git a/mcp/packages/browser-extension/README.md b/mcp/packages/browser-extension/README.md new file mode 100644 index 0000000000..ad0a784fd3 --- /dev/null +++ b/mcp/packages/browser-extension/README.md @@ -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 :/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. diff --git a/mcp/packages/browser-extension/public/manifest.json b/mcp/packages/browser-extension/public/manifest.json index 79147f4076..3efcc9e3fc 100644 --- a/mcp/packages/browser-extension/public/manifest.json +++ b/mcp/packages/browser-extension/public/manifest.json @@ -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" diff --git a/mcp/packages/browser-extension/public/popup.css b/mcp/packages/browser-extension/public/popup.css new file mode 100644 index 0000000000..383ef77692 --- /dev/null +++ b/mcp/packages/browser-extension/public/popup.css @@ -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); +} diff --git a/mcp/packages/browser-extension/public/popup.html b/mcp/packages/browser-extension/public/popup.html new file mode 100644 index 0000000000..15967ddc56 --- /dev/null +++ b/mcp/packages/browser-extension/public/popup.html @@ -0,0 +1,20 @@ + + + + + Penpot MCP Wake + + + +

Penpot MCP Wake

+ +

Paired sessions

+ +

MCP servers

+ + + + diff --git a/mcp/packages/browser-extension/scripts/build.mjs b/mcp/packages/browser-extension/scripts/build.mjs index 57d5615ba8..84aeb6af9b 100644 --- a/mcp/packages/browser-extension/scripts/build.mjs +++ b/mcp/packages/browser-extension/scripts/build.mjs @@ -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 }); 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..df4f77aa3f --- /dev/null +++ b/mcp/packages/browser-extension/src/PopupState.test.ts @@ -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/); +}); diff --git a/mcp/packages/browser-extension/src/PopupState.ts b/mcp/packages/browser-extension/src/PopupState.ts new file mode 100644 index 0000000000..c1d29600d8 --- /dev/null +++ b/mcp/packages/browser-extension/src/PopupState.ts @@ -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, + }; +} diff --git a/mcp/packages/browser-extension/src/background.ts b/mcp/packages/browser-extension/src/background.ts index 2db7251e70..d60f11daad 100644 --- a/mcp/packages/browser-extension/src/background.ts +++ b/mcp/packages/browser-extension/src/background.ts @@ -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") { diff --git a/mcp/packages/browser-extension/src/manifest.test.ts b/mcp/packages/browser-extension/src/manifest.test.ts index 8ad86bd785..5a209436c3 100644 --- a/mcp/packages/browser-extension/src/manifest.test.ts +++ b/mcp/packages/browser-extension/src/manifest.test.ts @@ -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" }); +}); diff --git a/mcp/packages/browser-extension/src/popup.ts b/mcp/packages/browser-extension/src/popup.ts new file mode 100644 index 0000000000..2e16a4c2e1 --- /dev/null +++ b/mcp/packages/browser-extension/src/popup.ts @@ -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 = { + 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 { + 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);