mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 14:00:37 +00:00
✨ Add the MCP browser extension and map sessions to tabs
Add mcp/packages/browser-extension, a Manifest V3 extension that will wake a frozen Penpot tab when an MCP task needs it. It is written in TypeScript, bundled with esbuild and imports the protocol types from mcp-common. The package joins the mcp workspace, so the build, test and fmt scripts of mcp/ cover it; the server release build skips it. Its content script runs on design.penpot.app and on local development instances. It asks the page for its MCP session on load, again when the page says it is ready and when the page returns from the back/forward cache, and forwards each valid session announcement to the service worker. It accepts only messages the page posted on its own window from its own origin, and posts its hello to that origin only. It also tells the worker when the page is hidden. The service worker records which tab holds each MCP session, with the MCP server URL and token the session's plugin uses. A tab holds the session it last announced as connected, until its page announces a disconnect, is hidden, or the tab is closed or replaced. The map lives in chrome.storage.session, so it survives service worker restarts and clears when the browser closes. While a tab holds a session the browser may not discard it automatically, since a discarded tab reloads and loses its MCP connection.
This commit is contained in:
parent
61d0f2be62
commit
d589fce019
2
mcp/packages/browser-extension/.gitignore
vendored
Normal file
2
mcp/packages/browser-extension/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
21
mcp/packages/browser-extension/package.json
Normal file
21
mcp/packages/browser-extension/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "mcp-browser-extension",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Browser extension that wakes frozen Penpot tabs for MCP tasks",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61",
|
||||
"scripts": {
|
||||
"build": "tsc && node ./scripts/build.mjs",
|
||||
"test": "tsx --test \"src/**/*.test.ts\"",
|
||||
"types:check": "tsc",
|
||||
"clean": "rm -rf dist/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@penpot/mcp-common": "workspace:../common",
|
||||
"@types/chrome": "^0.3.6",
|
||||
"esbuild": "^0.28.2",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
19
mcp/packages/browser-extension/public/manifest.json
Normal file
19
mcp/packages/browser-extension/public/manifest.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"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"],
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
11
mcp/packages/browser-extension/scripts/build.mjs
Normal file
11
mcp/packages/browser-extension/scripts/build.mjs
Normal file
@ -0,0 +1,11 @@
|
||||
import { build } from "esbuild";
|
||||
import { cp, rm } from "node:fs/promises";
|
||||
|
||||
const options = { bundle: true, target: "chrome116", outdir: "dist", logLevel: "info" };
|
||||
|
||||
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" });
|
||||
// content scripts load as classic scripts
|
||||
await build({ ...options, entryPoints: ["src/content.ts"], format: "iife" });
|
||||
await cp("public", "dist", { recursive: true });
|
||||
28
mcp/packages/browser-extension/src/DiscardGuard.ts
Normal file
28
mcp/packages/browser-extension/src/DiscardGuard.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { SessionChange, SessionMapObserver } from "./SessionMap.ts";
|
||||
|
||||
/** Browser control over the automatic discarding of tabs. */
|
||||
export interface DiscardControl {
|
||||
setAutoDiscardable(tabId: number, autoDiscardable: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the browser from discarding tabs that hold an MCP session, since a discarded tab reloads
|
||||
* and loses its MCP connection. Tabs regain automatic discarding when they stop holding a session.
|
||||
*/
|
||||
export class DiscardGuard implements SessionMapObserver {
|
||||
constructor(private readonly control: DiscardControl) {}
|
||||
|
||||
sessionsChanged(change: SessionChange): void {
|
||||
for (const tabId of change.tabsAdded) {
|
||||
this.set(tabId, false);
|
||||
}
|
||||
for (const tabId of change.tabsRemoved) {
|
||||
this.set(tabId, true);
|
||||
}
|
||||
}
|
||||
|
||||
private set(tabId: number, autoDiscardable: boolean): void {
|
||||
// the tab may have been closed meanwhile
|
||||
this.control.setAutoDiscardable(tabId, autoDiscardable).catch(() => {});
|
||||
}
|
||||
}
|
||||
106
mcp/packages/browser-extension/src/PageBridge.test.ts
Normal file
106
mcp/packages/browser-extension/src/PageBridge.test.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { beforeEach, test } from "node:test";
|
||||
import type { ContentMessage } from "./messages.ts";
|
||||
import { PageBridge, type PageWindow, type WorkerChannel } from "./PageBridge.ts";
|
||||
|
||||
const ORIGIN = "https://design.penpot.app";
|
||||
|
||||
/** Page recording what the bridge posts and dispatching events to it. */
|
||||
class FakePage implements PageWindow {
|
||||
readonly location = { origin: ORIGIN };
|
||||
readonly posted: { message: unknown; targetOrigin: string }[] = [];
|
||||
private readonly listeners = new Map<string, ((event: any) => void)[]>();
|
||||
|
||||
postMessage(message: unknown, targetOrigin: string): void {
|
||||
this.posted.push({ message, targetOrigin });
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: (event: any) => void): void {
|
||||
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
||||
}
|
||||
|
||||
dispatch(type: string, event: object = {}): void {
|
||||
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
||||
}
|
||||
|
||||
/** Posts a message on the page as the Penpot app does. */
|
||||
post(data: unknown, origin = ORIGIN, source: unknown = this): void {
|
||||
this.dispatch("message", { data, origin, source });
|
||||
}
|
||||
}
|
||||
|
||||
/** Worker channel recording what the bridge sends. */
|
||||
class FakeWorker implements WorkerChannel {
|
||||
readonly received: ContentMessage[] = [];
|
||||
|
||||
async sendMessage(message: ContentMessage): Promise<unknown> {
|
||||
this.received.push(message);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const announcement = {
|
||||
type: "penpot-mcp:session",
|
||||
sessionId: "abcdefghij",
|
||||
serverUrl: "wss://design.penpot.app/mcp/ws",
|
||||
token: "secret",
|
||||
status: "connected",
|
||||
};
|
||||
|
||||
let page: FakePage;
|
||||
let worker: FakeWorker;
|
||||
|
||||
beforeEach(() => {
|
||||
page = new FakePage();
|
||||
worker = new FakeWorker();
|
||||
new PageBridge(page, worker).start();
|
||||
});
|
||||
|
||||
test("asks the page for its session on start, restricted to the page's origin", () => {
|
||||
assert.deepEqual(page.posted, [{ message: { type: "penpot-mcp-extension:hello" }, targetOrigin: ORIGIN }]);
|
||||
});
|
||||
|
||||
test("says hello again when the page reports it has started listening", () => {
|
||||
page.post({ type: "penpot-mcp:ready" });
|
||||
page.post({ type: "penpot-mcp:ready" }, "https://evil.example");
|
||||
assert.equal(page.posted.length, 2);
|
||||
assert.deepEqual(page.posted[1], { message: { type: "penpot-mcp-extension:hello" }, targetOrigin: ORIGIN });
|
||||
});
|
||||
|
||||
test("forwards the page's session announcements to the worker", () => {
|
||||
page.post(announcement);
|
||||
assert.deepEqual(worker.received, [{ type: "session", announcement }]);
|
||||
});
|
||||
|
||||
test("ignores announcements from other origins or other windows", () => {
|
||||
page.post(announcement, "https://evil.example");
|
||||
page.post(announcement, ORIGIN, {});
|
||||
assert.deepEqual(worker.received, []);
|
||||
});
|
||||
|
||||
test("ignores invalid announcements and its own hello", () => {
|
||||
page.post({ ...announcement, status: "unknown" });
|
||||
page.post({ type: "penpot-mcp-extension:hello" });
|
||||
assert.deepEqual(worker.received, []);
|
||||
});
|
||||
|
||||
test("reports a hidden page and asks again when it returns from the back/forward cache", () => {
|
||||
page.dispatch("pagehide");
|
||||
page.dispatch("pageshow", { persisted: false });
|
||||
assert.equal(page.posted.length, 1);
|
||||
page.dispatch("pageshow", { persisted: true });
|
||||
assert.deepEqual(worker.received, [{ type: "page-hidden" }]);
|
||||
assert.equal(page.posted.length, 2);
|
||||
});
|
||||
|
||||
test("keeps working when the worker cannot be reached", () => {
|
||||
const failing = new FakePage();
|
||||
new PageBridge(failing, {
|
||||
sendMessage: () => {
|
||||
throw new Error("Extension context invalidated.");
|
||||
},
|
||||
}).start();
|
||||
failing.post(announcement);
|
||||
new PageBridge(failing, { sendMessage: () => Promise.reject(new Error("No receiver")) }).start();
|
||||
failing.post(announcement);
|
||||
});
|
||||
69
mcp/packages/browser-extension/src/PageBridge.ts
Normal file
69
mcp/packages/browser-extension/src/PageBridge.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import type { ExtensionHello } from "@penpot/mcp-common";
|
||||
import { parseAnnouncement, type ContentMessage } from "./messages.ts";
|
||||
|
||||
/** Page operations the bridge uses; the content script passes its window. */
|
||||
export interface PageWindow {
|
||||
readonly location: { readonly origin: string };
|
||||
postMessage(message: unknown, targetOrigin: string): void;
|
||||
addEventListener(type: string, listener: (event: any) => void): void;
|
||||
}
|
||||
|
||||
/** Channel from the content script to the service worker. */
|
||||
export interface WorkerChannel {
|
||||
sendMessage(message: ContentMessage): Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relays the MCP session announced by a Penpot page to the extension's service worker.
|
||||
*
|
||||
* The bridge asks the page for its session on load, when the page reports it has started
|
||||
* listening, and when the page returns from the back/forward cache. It accepts only messages the page posted on its own window from its own
|
||||
* origin.
|
||||
*/
|
||||
export class PageBridge {
|
||||
constructor(
|
||||
private readonly page: PageWindow,
|
||||
private readonly worker: WorkerChannel
|
||||
) {}
|
||||
|
||||
/** Starts listening to the page and asks it for its session. */
|
||||
start(): void {
|
||||
this.page.addEventListener("message", (event: MessageEvent) => this.handleMessage(event));
|
||||
this.page.addEventListener("pagehide", () => this.send({ type: "page-hidden" }));
|
||||
this.page.addEventListener("pageshow", (event: PageTransitionEvent) => {
|
||||
if (event.persisted) {
|
||||
this.sayHello();
|
||||
}
|
||||
});
|
||||
this.sayHello();
|
||||
}
|
||||
|
||||
private sayHello(): void {
|
||||
const hello: ExtensionHello = { type: "penpot-mcp-extension:hello" };
|
||||
this.page.postMessage(hello, this.page.location.origin);
|
||||
}
|
||||
|
||||
private handleMessage(event: MessageEvent): void {
|
||||
if (event.source !== this.page || event.origin !== this.page.location.origin) {
|
||||
return;
|
||||
}
|
||||
// the page started listening after the hello sent on load
|
||||
if (event.data?.type === "penpot-mcp:ready") {
|
||||
this.sayHello();
|
||||
return;
|
||||
}
|
||||
const announcement = parseAnnouncement(event.data);
|
||||
if (announcement) {
|
||||
this.send({ type: "session", announcement });
|
||||
}
|
||||
}
|
||||
|
||||
private send(message: ContentMessage): void {
|
||||
try {
|
||||
// the worker cannot be reached after the extension is reloaded or removed
|
||||
this.worker.sendMessage(message).catch(() => {});
|
||||
} catch {
|
||||
// ignored for the same reason
|
||||
}
|
||||
}
|
||||
}
|
||||
124
mcp/packages/browser-extension/src/SessionMap.test.ts
Normal file
124
mcp/packages/browser-extension/src/SessionMap.test.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { beforeEach, test } from "node:test";
|
||||
import type { SessionAnnouncement } from "@penpot/mcp-common";
|
||||
import { DiscardGuard } from "./DiscardGuard.ts";
|
||||
import { SessionMap, type SessionChange, type SessionEntry, type SessionStore } from "./SessionMap.ts";
|
||||
|
||||
/** In-memory store standing in for session storage. */
|
||||
class MemoryStore implements SessionStore {
|
||||
constructor(public saved?: Record<string, SessionEntry>) {}
|
||||
|
||||
async load() {
|
||||
return this.saved;
|
||||
}
|
||||
|
||||
async save(entries: Record<string, SessionEntry>) {
|
||||
this.saved = structuredClone(entries);
|
||||
}
|
||||
}
|
||||
|
||||
const TAB = { tabId: 1, windowId: 10 };
|
||||
const OTHER_TAB = { tabId: 2, windowId: 10 };
|
||||
|
||||
function announcement(
|
||||
status: SessionAnnouncement["status"],
|
||||
sessionId: string | null = "session"
|
||||
): SessionAnnouncement {
|
||||
return { type: "penpot-mcp:session", sessionId, serverUrl: "wss://penpot/mcp/ws", token: "secret", status };
|
||||
}
|
||||
|
||||
let store: MemoryStore;
|
||||
let changes: SessionChange[];
|
||||
let sessions: SessionMap;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new MemoryStore();
|
||||
changes = [];
|
||||
sessions = new SessionMap(store, { sessionsChanged: (change) => changes.push(change) });
|
||||
});
|
||||
|
||||
test("maps a connected session to the announcing tab and saves it", async () => {
|
||||
await sessions.announce(TAB, announcement("connected"));
|
||||
const entry = { ...TAB, serverUrl: "wss://penpot/mcp/ws", token: "secret" };
|
||||
assert.deepEqual(sessions.get("session"), entry);
|
||||
assert.deepEqual(store.saved, { session: entry });
|
||||
assert.deepEqual(changes, [{ tabsAdded: [1], tabsRemoved: [] }]);
|
||||
});
|
||||
|
||||
test("ignores announcements without a connected session", async () => {
|
||||
await sessions.announce(TAB, announcement("connecting", null));
|
||||
await sessions.announce(TAB, announcement("connected", null));
|
||||
await sessions.announce(TAB, announcement("error"));
|
||||
assert.deepEqual(sessions.all(), []);
|
||||
assert.deepEqual(changes, []);
|
||||
});
|
||||
|
||||
test("keeps the session while the plugin reconnects", async () => {
|
||||
await sessions.announce(TAB, announcement("connected"));
|
||||
await sessions.announce(TAB, announcement("connecting"));
|
||||
await sessions.announce(TAB, announcement("connected"));
|
||||
assert.equal(sessions.get("session")?.tabId, 1);
|
||||
assert.equal(changes.length, 1);
|
||||
});
|
||||
|
||||
test("forgets the tab's session when its page announces a disconnect", async () => {
|
||||
await sessions.announce(TAB, announcement("connected"));
|
||||
await sessions.announce(TAB, announcement("disconnected", null));
|
||||
assert.equal(sessions.get("session"), undefined);
|
||||
assert.deepEqual(changes.at(-1), { tabsAdded: [], tabsRemoved: [1] });
|
||||
});
|
||||
|
||||
test("a tab holds only the session it announced last", async () => {
|
||||
await sessions.announce(TAB, announcement("connected", "old"));
|
||||
await sessions.announce(TAB, announcement("connected", "new"));
|
||||
assert.equal(sessions.get("old"), undefined);
|
||||
assert.equal(sessions.get("new")?.tabId, 1);
|
||||
assert.deepEqual(changes.at(-1), { tabsAdded: [], tabsRemoved: [] });
|
||||
});
|
||||
|
||||
test("forgets only the sessions of a removed tab", async () => {
|
||||
await sessions.announce(TAB, announcement("connected", "first"));
|
||||
await sessions.announce(OTHER_TAB, announcement("connected", "second"));
|
||||
await sessions.removeTab(1);
|
||||
assert.deepEqual(
|
||||
sessions.all().map(([sessionId]) => sessionId),
|
||||
["second"]
|
||||
);
|
||||
assert.deepEqual(changes.at(-1), { tabsAdded: [], tabsRemoved: [1] });
|
||||
});
|
||||
|
||||
test("restores the saved sessions when the worker restarts", async () => {
|
||||
await sessions.announce(TAB, announcement("connected"));
|
||||
const restarted = new SessionMap(store, { sessionsChanged: () => {} });
|
||||
await restarted.ready();
|
||||
assert.equal(restarted.get("session")?.tabId, 1);
|
||||
});
|
||||
|
||||
test("changes made while restoring apply to the restored sessions", async () => {
|
||||
const saved = new MemoryStore({ first: { ...TAB, serverUrl: "wss://penpot/mcp/ws", token: null } });
|
||||
const restored = new SessionMap(saved, { sessionsChanged: () => {} });
|
||||
await restored.announce(OTHER_TAB, announcement("connected", "second"));
|
||||
assert.deepEqual(Object.keys(saved.saved!).sort(), ["first", "second"]);
|
||||
});
|
||||
|
||||
test("the discard guard protects tabs holding a session and releases them after", async () => {
|
||||
const calls: [number, boolean][] = [];
|
||||
const guard = new DiscardGuard({
|
||||
setAutoDiscardable: async (tabId, autoDiscardable) => {
|
||||
calls.push([tabId, autoDiscardable]);
|
||||
},
|
||||
});
|
||||
const guarded = new SessionMap(new MemoryStore(), guard);
|
||||
await guarded.announce(TAB, announcement("connected"));
|
||||
await guarded.removeTab(1);
|
||||
assert.deepEqual(calls, [
|
||||
[1, false],
|
||||
[1, true],
|
||||
]);
|
||||
});
|
||||
|
||||
test("the discard guard ignores tabs closed meanwhile", async () => {
|
||||
const guard = new DiscardGuard({ setAutoDiscardable: () => Promise.reject(new Error("No tab with id")) });
|
||||
guard.sessionsChanged({ tabsAdded: [1], tabsRemoved: [2] });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
125
mcp/packages/browser-extension/src/SessionMap.ts
Normal file
125
mcp/packages/browser-extension/src/SessionMap.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import type { SessionAnnouncement } from "@penpot/mcp-common";
|
||||
|
||||
/** The tab holding an MCP session and the server the session's plugin connects to. */
|
||||
export interface SessionEntry {
|
||||
tabId: number;
|
||||
windowId: number;
|
||||
serverUrl: string;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
/** Persistence of the session map across service worker restarts. */
|
||||
export interface SessionStore {
|
||||
load(): Promise<Record<string, SessionEntry> | undefined>;
|
||||
save(entries: Record<string, SessionEntry>): Promise<void>;
|
||||
}
|
||||
|
||||
/** Change of the tabs holding sessions, reported after each change of the map. */
|
||||
export interface SessionChange {
|
||||
/** tabs that hold a session and held none before. */
|
||||
tabsAdded: number[];
|
||||
/** tabs that held a session and hold none now. */
|
||||
tabsRemoved: number[];
|
||||
}
|
||||
|
||||
/** Receiver of the changes of a session map. */
|
||||
export interface SessionMapObserver {
|
||||
sessionsChanged(change: SessionChange): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map from MCP session IDs to the tabs holding them.
|
||||
*
|
||||
* A tab holds the session it last announced as connected, until it announces a disconnect, is
|
||||
* hidden or is closed. The map is saved after each change and restored when the service worker
|
||||
* restarts.
|
||||
*/
|
||||
export class SessionMap {
|
||||
private entries = new Map<string, SessionEntry>();
|
||||
private readonly loaded: Promise<void>;
|
||||
|
||||
constructor(
|
||||
private readonly store: SessionStore,
|
||||
private readonly observer: SessionMapObserver
|
||||
) {
|
||||
this.loaded = store.load().then(
|
||||
(saved) => {
|
||||
this.entries = new Map(Object.entries(saved ?? {}));
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolves once the saved entries have been restored. */
|
||||
ready(): Promise<void> {
|
||||
return this.loaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a session announcement made by the page in a tab.
|
||||
*
|
||||
* @param tab - The tab and window of the announcing page
|
||||
* @param announcement - The page's announcement
|
||||
*/
|
||||
async announce(tab: { tabId: number; windowId: number }, announcement: SessionAnnouncement): Promise<void> {
|
||||
await this.loaded;
|
||||
if (announcement.status === "disconnected") {
|
||||
await this.change(() => this.deleteTab(tab.tabId));
|
||||
} else if (announcement.status === "connected" && announcement.sessionId) {
|
||||
const { sessionId, serverUrl, token } = announcement;
|
||||
await this.change(() => {
|
||||
// a tab holds a single session
|
||||
this.deleteTab(tab.tabId);
|
||||
this.entries.set(sessionId, { ...tab, serverUrl, token });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets the sessions held by a tab.
|
||||
*
|
||||
* @param tabId - The tab whose page was hidden, or which was closed or replaced
|
||||
*/
|
||||
async removeTab(tabId: number): Promise<void> {
|
||||
await this.loaded;
|
||||
await this.change(() => this.deleteTab(tabId));
|
||||
}
|
||||
|
||||
/** Returns the entry of a session, if a tab holds it. */
|
||||
get(sessionId: string): SessionEntry | undefined {
|
||||
return this.entries.get(sessionId);
|
||||
}
|
||||
|
||||
/** Returns all sessions with their entries. */
|
||||
all(): [string, SessionEntry][] {
|
||||
return [...this.entries];
|
||||
}
|
||||
|
||||
/** Applies a change, then saves the map and reports the change if any tab gained or lost a session. */
|
||||
private async change(apply: () => void): Promise<void> {
|
||||
const before = this.tabs();
|
||||
const snapshot = JSON.stringify([...this.entries]);
|
||||
apply();
|
||||
if (JSON.stringify([...this.entries]) === snapshot) {
|
||||
return;
|
||||
}
|
||||
const after = this.tabs();
|
||||
await this.store.save(Object.fromEntries(this.entries));
|
||||
this.observer.sessionsChanged({
|
||||
tabsAdded: [...after].filter((tabId) => !before.has(tabId)),
|
||||
tabsRemoved: [...before].filter((tabId) => !after.has(tabId)),
|
||||
});
|
||||
}
|
||||
|
||||
private deleteTab(tabId: number): void {
|
||||
for (const [sessionId, entry] of [...this.entries]) {
|
||||
if (entry.tabId === tabId) {
|
||||
this.entries.delete(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private tabs(): Set<number> {
|
||||
return new Set([...this.entries.values()].map((entry) => entry.tabId));
|
||||
}
|
||||
}
|
||||
34
mcp/packages/browser-extension/src/background.ts
Normal file
34
mcp/packages/browser-extension/src/background.ts
Normal file
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Service worker of the Penpot MCP wake extension.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
import { ChromeDiscardControl, ChromeSessionStore } from "./chrome.ts";
|
||||
import { DiscardGuard } from "./DiscardGuard.ts";
|
||||
import { parseAnnouncement, type ContentMessage } from "./messages.ts";
|
||||
import { SessionMap } from "./SessionMap.ts";
|
||||
|
||||
const sessions = new SessionMap(new ChromeSessionStore(), new DiscardGuard(new ChromeDiscardControl()));
|
||||
|
||||
chrome.runtime.onMessage.addListener((message: ContentMessage, sender) => {
|
||||
const tab = sender.tab;
|
||||
if (tab?.id === undefined || sender.id !== chrome.runtime.id) {
|
||||
return;
|
||||
}
|
||||
if (message?.type === "page-hidden") {
|
||||
void sessions.removeTab(tab.id);
|
||||
} else if (message?.type === "session") {
|
||||
const announcement = parseAnnouncement(message.announcement);
|
||||
if (announcement) {
|
||||
void sessions.announce({ tabId: tab.id, windowId: tab.windowId }, announcement);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chrome.tabs.onRemoved.addListener((tabId) => void sessions.removeTab(tabId));
|
||||
|
||||
chrome.tabs.onReplaced.addListener((_addedTabId, removedTabId) => void sessions.removeTab(removedTabId));
|
||||
26
mcp/packages/browser-extension/src/chrome.ts
Normal file
26
mcp/packages/browser-extension/src/chrome.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { DiscardControl } from "./DiscardGuard.ts";
|
||||
import type { SessionEntry, SessionStore } from "./SessionMap.ts";
|
||||
|
||||
const SESSIONS_KEY = "sessions";
|
||||
|
||||
/**
|
||||
* Session store in `chrome.storage.session`, which lives in memory, survives service worker
|
||||
* restarts and clears when the browser closes.
|
||||
*/
|
||||
export class ChromeSessionStore implements SessionStore {
|
||||
async load(): Promise<Record<string, SessionEntry> | undefined> {
|
||||
const stored = await chrome.storage.session.get(SESSIONS_KEY);
|
||||
return stored[SESSIONS_KEY] as Record<string, SessionEntry> | undefined;
|
||||
}
|
||||
|
||||
async save(entries: Record<string, SessionEntry>): Promise<void> {
|
||||
await chrome.storage.session.set({ [SESSIONS_KEY]: entries });
|
||||
}
|
||||
}
|
||||
|
||||
/** Discard control through the Chrome tabs API. */
|
||||
export class ChromeDiscardControl implements DiscardControl {
|
||||
async setAutoDiscardable(tabId: number, autoDiscardable: boolean): Promise<void> {
|
||||
await chrome.tabs.update(tabId, { autoDiscardable });
|
||||
}
|
||||
}
|
||||
3
mcp/packages/browser-extension/src/content.ts
Normal file
3
mcp/packages/browser-extension/src/content.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { PageBridge } from "./PageBridge.ts";
|
||||
|
||||
new PageBridge(window, chrome.runtime).start();
|
||||
25
mcp/packages/browser-extension/src/manifest.test.ts
Normal file
25
mcp/packages/browser-extension/src/manifest.test.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const manifest = JSON.parse(await readFile(new URL("../public/manifest.json", import.meta.url), "utf8"));
|
||||
|
||||
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("requests no permission beyond storage and alarms", () => {
|
||||
assert.deepEqual(manifest.permissions, ["storage", "alarms"]);
|
||||
assert.equal(manifest.host_permissions, 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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
44
mcp/packages/browser-extension/src/messages.test.ts
Normal file
44
mcp/packages/browser-extension/src/messages.test.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { parseAnnouncement } from "./messages.ts";
|
||||
|
||||
const announcement = {
|
||||
type: "penpot-mcp:session",
|
||||
sessionId: "abcdefghij",
|
||||
serverUrl: "wss://design.penpot.app/mcp/ws",
|
||||
token: "secret",
|
||||
status: "connected",
|
||||
};
|
||||
|
||||
test("accepts a session announcement", () => {
|
||||
assert.deepEqual(parseAnnouncement(announcement), announcement);
|
||||
});
|
||||
|
||||
test("accepts an announcement without a session ID or token", () => {
|
||||
const pending = { ...announcement, sessionId: null, token: null, status: "connecting" };
|
||||
assert.deepEqual(parseAnnouncement(pending), pending);
|
||||
});
|
||||
|
||||
test("accepts a server URL with an https scheme", () => {
|
||||
const https = { ...announcement, serverUrl: "https://localhost:3449/mcp/ws" };
|
||||
assert.deepEqual(parseAnnouncement(https), https);
|
||||
});
|
||||
|
||||
test("keeps only the announcement fields", () => {
|
||||
assert.deepEqual(parseAnnouncement({ ...announcement, extra: 1 }), announcement);
|
||||
});
|
||||
|
||||
test("rejects other messages and malformed announcements", () => {
|
||||
for (const data of [
|
||||
null,
|
||||
"penpot-mcp:session",
|
||||
{ ...announcement, type: "penpot-mcp-extension:hello" },
|
||||
{ ...announcement, sessionId: "" },
|
||||
{ ...announcement, sessionId: 42 },
|
||||
{ ...announcement, serverUrl: "ftp://design.penpot.app/mcp/ws" },
|
||||
{ ...announcement, token: undefined },
|
||||
{ ...announcement, status: "frozen" },
|
||||
]) {
|
||||
assert.equal(parseAnnouncement(data), null, JSON.stringify(data));
|
||||
}
|
||||
});
|
||||
39
mcp/packages/browser-extension/src/messages.ts
Normal file
39
mcp/packages/browser-extension/src/messages.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import type { McpConnectionStatus, SessionAnnouncement } from "@penpot/mcp-common";
|
||||
|
||||
/**
|
||||
* Message sent by the content script to the service worker.
|
||||
*
|
||||
* - `session`: the page announced its MCP session
|
||||
* - `page-hidden`: the page is being unloaded or stored in the back/forward cache
|
||||
*/
|
||||
export type ContentMessage = { type: "session"; announcement: SessionAnnouncement } | { type: "page-hidden" };
|
||||
|
||||
const STATUSES: readonly McpConnectionStatus[] = ["idle", "connecting", "connected", "disconnected", "error"];
|
||||
|
||||
function isOptionalString(value: unknown, maxLength: number): value is string | null {
|
||||
return value === null || (typeof value === "string" && value.length > 0 && value.length <= maxLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a session announcement posted on the page.
|
||||
*
|
||||
* @param data - The data of a window message
|
||||
* @returns The announcement, or null if the data is not a valid announcement
|
||||
*/
|
||||
export function parseAnnouncement(data: unknown): SessionAnnouncement | null {
|
||||
if (typeof data !== "object" || data === null) {
|
||||
return null;
|
||||
}
|
||||
const { type, sessionId, serverUrl, token, status } = data as Record<string, unknown>;
|
||||
if (
|
||||
type !== "penpot-mcp:session" ||
|
||||
!isOptionalString(sessionId, 128) ||
|
||||
typeof serverUrl !== "string" ||
|
||||
!/^(wss?|https?):\/\//.test(serverUrl) ||
|
||||
!isOptionalString(token, 1024) ||
|
||||
!STATUSES.includes(status as McpConnectionStatus)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { type, sessionId, serverUrl, token, status: status as McpConnectionStatus };
|
||||
}
|
||||
20
mcp/packages/browser-extension/tsconfig.json
Normal file
20
mcp/packages/browser-extension/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["chrome"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/*.test.ts"]
|
||||
}
|
||||
30
mcp/pnpm-lock.yaml
generated
30
mcp/pnpm-lock.yaml
generated
@ -177,6 +177,24 @@ importers:
|
||||
specifier: ^3.9.9
|
||||
version: 3.9.9
|
||||
|
||||
packages/browser-extension:
|
||||
devDependencies:
|
||||
'@penpot/mcp-common':
|
||||
specifier: workspace:../common
|
||||
version: link:../common
|
||||
'@types/chrome':
|
||||
specifier: ^0.3.6
|
||||
version: 0.3.6
|
||||
esbuild:
|
||||
specifier: ^0.28.2
|
||||
version: 0.28.2
|
||||
tsx:
|
||||
specifier: ^4.23.15
|
||||
version: 4.23.15
|
||||
typescript:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
|
||||
packages/common:
|
||||
devDependencies:
|
||||
typescript:
|
||||
@ -783,6 +801,9 @@ packages:
|
||||
'@types/body-parser@1.19.6':
|
||||
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
|
||||
|
||||
'@types/chrome@0.3.6':
|
||||
resolution: {integrity: sha512-NXCrCBefCHCKMVbkaxfBQqyP766Wg85S2FQhixCCxq7AtjJwoEaja2y5NSqCzpihIi9mPqhaF5RuybTnrdYD2Q==}
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
@ -792,6 +813,9 @@ packages:
|
||||
'@types/express@5.0.6':
|
||||
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
|
||||
|
||||
'@types/har-format@1.2.16':
|
||||
resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==}
|
||||
|
||||
'@types/http-errors@2.0.5':
|
||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||
|
||||
@ -1915,6 +1939,10 @@ snapshots:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 26.6.4
|
||||
|
||||
'@types/chrome@0.3.6':
|
||||
dependencies:
|
||||
'@types/har-format': 1.2.16
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 26.6.4
|
||||
@ -1932,6 +1960,8 @@ snapshots:
|
||||
'@types/express-serve-static-core': 5.1.3
|
||||
'@types/serve-static': 2.2.0
|
||||
|
||||
'@types/har-format@1.2.16': {}
|
||||
|
||||
'@types/http-errors@2.0.5': {}
|
||||
|
||||
'@types/js-yaml@4.0.9': {}
|
||||
|
||||
@ -19,6 +19,7 @@ packages:
|
||||
- "packages/common"
|
||||
- "packages/server"
|
||||
- "packages/plugin"
|
||||
- "packages/browser-extension"
|
||||
|
||||
overrides:
|
||||
'@hono/node-server@<2.0.5': ^2.0.5
|
||||
|
||||
@ -21,7 +21,7 @@ set +e
|
||||
set -e
|
||||
popd
|
||||
|
||||
pnpm -r --filter "!mcp-plugin" install;
|
||||
pnpm -r --filter "!mcp-plugin" --filter "!mcp-browser-extension" install;
|
||||
pnpm -r --filter "mcp-server" run build;
|
||||
|
||||
rsync -avr packages/server/dist/ ./dist/;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user