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