mirror of
https://github.com/penpot/penpot.git
synced 2026-11-04 00:50:44 +00:00
✨ Add mcp browser extension (#12627)
* 🐛 Fail MCP tasks at once when their plugin connection closes A response only counts when it comes from the connection the task went to, so a task sent over a connection that then closes can never finish. It used to wait for its full timeout. The plugin bridge now fails such tasks as soon as their connection closes. Connections also log their close code and reason, to tell a page closing its socket from a browser or network failure. * ✨ Announce the MCP session to a browser extension Let a browser extension learn which tab holds an MCP session, so it can keep the browser from discarding that tab. While the MCP plugin runs, the page listens for the extension's hello message, and says it is ready once it listens, so that a content script that said hello too early says it again. After a hello, the page posts its session ID and connection status, and posts them again on every status change. When the plugin stops, it reports the session as disconnected. The plugin also reports "disconnected" while it reconnects on its own, for instance after a page resume, so the page announces "connecting" then and "disconnected" only once the user has stopped the connection. Otherwise the extension would forget the session at every reconnect. The page only accepts messages posted on its own window by its own origin, posts to its own origin only, and sends nothing before the extension asks. It reaches the window through app.util.globals, whose stand-in for the unit tests accepts postMessage. * ✨ Keep Penpot tabs connected to MCP from being frozen Chrome freezes some background tabs, for instance busy ones while Battery Saver is on, and a frozen page cannot run the MCP plugin. Chrome does not freeze a page that holds a Web Lock, so the page holds a lock named penpot-mcp-session from the moment the user asks to connect until the connection stops: on disconnect, logout, MCP being turned off or the MCP runtime starting again. The lock is shared, so several tabs can hold it at once. Browsers without Web Locks skip it, and when the lock request fails, the next connection request tries again. Firefox does not freeze background pages, so the lock changes nothing there. * ✨ Add a browser extension that keeps MCP tabs from being discarded A browser may discard a background tab to save memory. A discarded Penpot tab reloads when the user returns to it and loses its MCP session. Add mcp/packages/browser-extension, a Manifest V3 extension for Chrome and Firefox, built from one source into dist/chrome and dist/firefox. 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, on the test environments under penpot.dev 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 background. 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 background when the page is hidden. The background records which tab holds each MCP session. 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 restarts of the background and clears when the browser closes. While a tab holds a session, the browser may not discard it automatically. The extension asks only for the storage permission. Chrome runs the background as a service worker; Firefox runs it as an event page and needs the add-on ID, mcp-extension@penpot.app, and Firefox 140. The toolbar popup lists the connected tabs. The icon is the Penpot logo, white on a black rounded square, with the SVG source next to the PNGs. A README explains how the extension works and how to build and load it. * 🐛 Ask MCP plugins for heartbeats so hidden tabs do not look frozen The server refuses to send a task to a plugin whose last heartbeat is older than 30 s, taking its tab for frozen. The plugin sent heartbeats only from a 10 s timer, and Chrome runs the timers of a hidden page as rarely as once a minute: without Energy Saver after about a minute hidden, and at once with it. A Penpot tab hidden for a minute therefore failed that check about half the time, although it could run tasks. Chrome handles socket messages of a hidden page at once, and a frozen page cannot handle them at all. The server now sends each plugin a heartbeat request every 10 s, and the plugin answers it with a heartbeat, so the heartbeat age again tells a frozen tab from a hidden one. The plugin keeps its timer, so it still sends heartbeats to a server that does not ask. A plugin that does not know the request passes it to the plugin code, which ignores it.
This commit is contained in:
parent
2f57d380db
commit
43f3bda8b5
@ -17,6 +17,7 @@
|
||||
[app.main.data.profile :as du]
|
||||
[app.main.store :as st]
|
||||
[app.plugins.register :as preg]
|
||||
[app.util.globals :as globals]
|
||||
[app.util.timers :as ts]
|
||||
[beicon.v2.core :as rx]
|
||||
[beicon.v2.operators :as rxo]
|
||||
@ -44,6 +45,32 @@
|
||||
(defonce interval-sub
|
||||
(atom nil))
|
||||
|
||||
;; Resolves the promise that keeps the session Web Lock held; nil when none is.
|
||||
(defonce ^:private session-lock-release
|
||||
(atom nil))
|
||||
|
||||
(defn- hold-session-lock
|
||||
"Holds a shared Web Lock while the MCP connection is requested. Chrome
|
||||
does not freeze a page that holds a Web Lock, and a frozen page cannot
|
||||
run the plugin."
|
||||
[]
|
||||
(when-let [locks (and (nil? @session-lock-release)
|
||||
(unchecked-get globals/navigator "locks"))]
|
||||
(let [released (js/Promise. (fn [resolve] (reset! session-lock-release resolve)))
|
||||
release @session-lock-release]
|
||||
(-> (.request locks "penpot-mcp-session" #js {:mode "shared"} (constantly released))
|
||||
(.catch (fn [cause]
|
||||
;; lets a later connection request try again, unless a
|
||||
;; newer lock request has already replaced this one
|
||||
(compare-and-set! session-lock-release release nil)
|
||||
(log/warn :hint "unable to hold the MCP session lock" :cause cause)))))))
|
||||
|
||||
(defn- release-session-lock
|
||||
[]
|
||||
(when-let [release @session-lock-release]
|
||||
(reset! session-lock-release nil)
|
||||
(release)))
|
||||
|
||||
(defn connect-mcp
|
||||
[]
|
||||
(ptk/reify ::connect-mcp
|
||||
@ -60,7 +87,12 @@
|
||||
(watch [_ state _]
|
||||
(if (get-in state [:mcp :connection-requested])
|
||||
(rx/of (ptk/data-event ::connect))
|
||||
(rx/empty)))))
|
||||
(rx/empty)))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(when (get-in state [:mcp :connection-requested])
|
||||
(hold-session-lock)))))
|
||||
|
||||
(defn- start-reconnect-watcher
|
||||
[]
|
||||
@ -133,8 +165,38 @@
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ _ _]
|
||||
(release-session-lock)
|
||||
(stop-reconnect-watcher!))))
|
||||
|
||||
(defn- extension-hello?
|
||||
"Checks if a window message is a browser extension's request for the
|
||||
MCP session, posted on this page by its content script."
|
||||
[event]
|
||||
(and (identical? (unchecked-get event "source") globals/window)
|
||||
(= (unchecked-get event "origin") (.-origin globals/location))
|
||||
(= "penpot-mcp-extension:hello"
|
||||
(some-> (unchecked-get event "data") (unchecked-get "type")))))
|
||||
|
||||
(defn extension-status
|
||||
"Returns the connection status to announce to a browser extension. The
|
||||
plugin also reports `disconnected` while it reconnects, so the
|
||||
extension, which forgets a session on `disconnected`, only hears it
|
||||
once the user has stopped the connection."
|
||||
[status connection-requested?]
|
||||
(if (and (= status "disconnected") connection-requested?)
|
||||
"connecting"
|
||||
status))
|
||||
|
||||
(defn- announce-session
|
||||
"Posts the MCP session of this page to a browser extension, which keeps
|
||||
this tab from being discarded."
|
||||
[status session-id]
|
||||
(.postMessage globals/window
|
||||
#js {:type "penpot-mcp:session"
|
||||
:sessionId session-id
|
||||
:status status}
|
||||
(.-origin globals/location)))
|
||||
|
||||
(defn- init-mcp-plugin
|
||||
"Waits the plugin runtime and initializes the bundled MCP plugin."
|
||||
[{:keys [token]}]
|
||||
@ -151,6 +213,19 @@
|
||||
(rx/filter (ptk/type? ::init) stream))
|
||||
|
||||
active? (atom true)
|
||||
|
||||
;; Whether a browser extension asked for the session; nothing is
|
||||
;; posted on the page before that.
|
||||
announce? (atom false)
|
||||
|
||||
on-message
|
||||
(fn [event]
|
||||
(when (extension-hello? event)
|
||||
(reset! announce? true)
|
||||
(let [{:keys [connection-status connection-requested session-id]} (get @st/state :mcp)]
|
||||
(announce-session (extension-status connection-status connection-requested)
|
||||
session-id))))
|
||||
|
||||
extension #js {:getToken (constantly token)
|
||||
:getServerUrl #(str cf/mcp-ws-uri)
|
||||
:isConnectionRequested #(and @active?
|
||||
@ -162,6 +237,9 @@
|
||||
(get-in @st/state [:mcp :connection-requested]))
|
||||
(start-reconnect-watcher))
|
||||
(st/emit! (update-mcp-connection-status status session-id))
|
||||
(when @announce?
|
||||
(announce-session (extension-status status (get-in @st/state [:mcp :connection-requested]))
|
||||
session-id))
|
||||
(log/info :hint "MCP STATUS" :status status)))
|
||||
|
||||
:on
|
||||
@ -182,8 +260,16 @@
|
||||
(rx/take 1)
|
||||
(rx/subs! (fn [_]
|
||||
(reset! active? false)
|
||||
(.removeEventListener globals/window "message" on-message)
|
||||
(when @announce?
|
||||
(announce-session "disconnected" nil))
|
||||
(release-session-lock)
|
||||
(stop-reconnect-watcher!)
|
||||
(dp/close-plugin! default-manifest))))
|
||||
(.addEventListener globals/window "message" on-message)
|
||||
;; A content script that said hello before this listener existed
|
||||
;; says it again
|
||||
(.postMessage globals/window #js {:type "penpot-mcp:ready"} (.-origin globals/location))
|
||||
(dp/start-plugin! manifest #js {:mcp extension})))))
|
||||
|
||||
(defn- stop-mcp-plugin
|
||||
|
||||
@ -84,6 +84,9 @@ goog.scope(function () {
|
||||
forward() {},
|
||||
go() {},
|
||||
};
|
||||
// `app.main.data.workspace.mcp` talks to a browser extension through
|
||||
// window messages; nothing receives them here.
|
||||
mockWindow.postMessage = function () {};
|
||||
return mockWindow;
|
||||
}
|
||||
})();
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
[app.main.data.profile :as du]
|
||||
[app.main.data.workspace.mcp :as mcp]
|
||||
[app.plugins.register :as preg]
|
||||
[app.util.globals :as globals]
|
||||
[beicon.v2.core :as rx]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.async :as a]
|
||||
@ -58,6 +59,18 @@
|
||||
(t/is (false? (get-in result [:profile :props :mcp-enabled])))
|
||||
(t/is (false? (get-in result [:mcp :enabled]))))))
|
||||
|
||||
(t/deftest test-extension-status
|
||||
(t/testing "keeps a session the plugin is reconnecting"
|
||||
(t/is (= "connecting" (mcp/extension-status "disconnected" true))))
|
||||
|
||||
(t/testing "reports the disconnect once the user has stopped the connection"
|
||||
(t/is (= "disconnected" (mcp/extension-status "disconnected" false)))
|
||||
(t/is (= "disconnected" (mcp/extension-status "disconnected" nil))))
|
||||
|
||||
(t/testing "passes other statuses through"
|
||||
(t/is (= "connected" (mcp/extension-status "connected" true)))
|
||||
(t/is (= "error" (mcp/extension-status "error" true)))))
|
||||
|
||||
(t/deftest test-update-mcp-connection-status
|
||||
(t/testing "sets connection status to connected"
|
||||
(let [state {:mcp {:connection-requested true :connection-status "disconnected"}}
|
||||
@ -171,6 +184,47 @@
|
||||
:on-next #(swap! events conj (ptk/type %))))
|
||||
(t/is (= [:app.main.data.workspace.mcp/connect] @events))))
|
||||
|
||||
(defn- connect
|
||||
"Runs the effect of a connection request made with the given MCP state."
|
||||
[mcp-state]
|
||||
(let [event (mcp/connect-mcp)]
|
||||
(ptk/effect event (ptk/update event {:mcp mcp-state}) nil)))
|
||||
|
||||
(t/deftest ^:async test-session-lock-follows-connection-request
|
||||
(let [requests (atom [])
|
||||
released (atom 0)
|
||||
locks #js {:request (fn [lock-name options callback]
|
||||
(swap! requests conj [lock-name (.-mode options)])
|
||||
(.then (callback nil) #(swap! released inc)))}]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{globals/navigator #js {:locks locks}}
|
||||
(connect {:enabled false})
|
||||
(t/is (empty? @requests))
|
||||
(connect {:enabled true :token-valid true})
|
||||
(connect {:enabled true :token-valid true})
|
||||
(t/is (= [["penpot-mcp-session" "shared"]] @requests))
|
||||
(ptk/effect (mcp/user-disconnect-mcp) nil nil)
|
||||
(await (a/settle))
|
||||
(t/is (= 1 @released))
|
||||
(ptk/effect (mcp/user-disconnect-mcp) nil nil)
|
||||
(await (a/settle))
|
||||
(t/is (= 1 @released))))))
|
||||
|
||||
(t/deftest ^:async test-session-lock-retried-after-failed-request
|
||||
(let [requests (atom 0)
|
||||
locks #js {:request (fn [_ _ _]
|
||||
(swap! requests inc)
|
||||
(js/Promise.reject (js/Error. "lock request denied")))}]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{globals/navigator #js {:locks locks}}
|
||||
(connect {:enabled true :token-valid true})
|
||||
(await (a/settle))
|
||||
(connect {:enabled true :token-valid true})
|
||||
(await (a/settle))
|
||||
(t/is (= 2 @requests))))))
|
||||
|
||||
(t/deftest test-disconnect-clears-connection-intent
|
||||
(let [state {:mcp {:connection-requested true
|
||||
:connection-status "connecting"
|
||||
|
||||
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
|
||||
53
mcp/packages/browser-extension/README.md
Normal file
53
mcp/packages/browser-extension/README.md
Normal file
@ -0,0 +1,53 @@
|
||||
# Penpot MCP
|
||||
|
||||
A Chrome and Firefox extension that keeps the browser from discarding Penpot tabs connected to
|
||||
MCP. A discarded tab reloads when the user returns to it, and loses its MCP session.
|
||||
|
||||
## How it works
|
||||
|
||||
- The content script asks the Penpot page for its MCP session. The page answers with the
|
||||
session ID and connection status.
|
||||
- The background (a service worker in Chrome, an event page in Firefox) maps each session to
|
||||
its tab and marks the tab as not discardable (`autoDiscardable: false`) until the session
|
||||
ends.
|
||||
|
||||
The popup lists the connected tabs.
|
||||
|
||||
The icon is the Penpot logo; `public/icons/icon.svg` is its source. To redraw the PNGs after
|
||||
changing it:
|
||||
|
||||
```sh
|
||||
for size in 16 32 48 128; do
|
||||
rsvg-convert -w $size -h $size public/icons/icon.svg -o public/icons/icon-$size.png
|
||||
done
|
||||
```
|
||||
|
||||
## Frozen tabs
|
||||
|
||||
Chrome can also freeze a background page, which then cannot run the MCP plugin. The extension
|
||||
does not handle this: the Penpot page holds a shared Web Lock while its MCP connection is
|
||||
requested, and Chrome does not freeze a page holding one. Firefox does not freeze background
|
||||
pages.
|
||||
|
||||
## Build and load
|
||||
|
||||
From `mcp/`:
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm --filter mcp-common run build
|
||||
pnpm --filter mcp-browser-extension run build
|
||||
```
|
||||
|
||||
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`, on the test environments under
|
||||
`https://*.penpot.dev` and on `localhost` at any port.
|
||||
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 keeps Penpot tabs connected to MCP from being discarded",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
BIN
mcp/packages/browser-extension/public/icons/icon-128.png
Normal file
BIN
mcp/packages/browser-extension/public/icons/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
BIN
mcp/packages/browser-extension/public/icons/icon-16.png
Normal file
BIN
mcp/packages/browser-extension/public/icons/icon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 472 B |
BIN
mcp/packages/browser-extension/public/icons/icon-32.png
Normal file
BIN
mcp/packages/browser-extension/public/icons/icon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
mcp/packages/browser-extension/public/icons/icon-48.png
Normal file
BIN
mcp/packages/browser-extension/public/icons/icon-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
6
mcp/packages/browser-extension/public/icons/icon.svg
Normal file
6
mcp/packages/browser-extension/public/icons/icon.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<rect width="128" height="128" rx="28" fill="#000000" />
|
||||
<svg x="12" y="12" width="104" height="104" viewBox="-42 0 346 346">
|
||||
<path fill="#ffffff" transform="translate(136.64258,-280.3457)" d="m -68.324219,280.3457 -36.376951,51.23633 0,28.96289 -31.65625,15.05664 -0.28516,-0.13476 0,11.90234 0,177.05078 123.417971,58.30664 7.5429684,3.56055 7.5410156,-3.56055 123.419925,-58.30664 0,-188.95312 -0.23242,0.10937 -31.666021,-15.06055 0,-28.93359 -1.107421,-1.55859 -35.271485,-49.67774 -36.378906,51.23633 0,0.0508 -26.4707032,-37.28125 -26.2656248,36.99414 -0.939453,-1.32226 -35.271485,-49.67774 z m 6.435547,29.76172 14.070313,19.81641 -41.011719,0 13.90625,-19.58399 13.035156,-0.23242 z m 125.326172,0 14.070312,19.81641 -41.011718,0 13.904297,-19.58399 13.037109,-0.23242 z m -62.84960938,14.00781 14.07031238,19.81446 -41.009765,0 13.904296,-19.58399 13.03515662,-0.23047 z m -93.30664062,15.79493 19.451172,0 0,65.49609 -19.451172,-9.18945 0,-56.30664 z m 29.4375,0 19.353516,0 -0.002,79.35546 -19.353515,-9.14257 0.002,-70.21289 z m 95.886719,0 19.453125,0 -0.002,70.14648 -19.449219,9.1875 -0.002,-79.33398 z m 29.4375,0 19.353515,0 0,56.28515 -19.353515,9.14258 0,-65.42773 z m -92.285157,14.00586 19.451172,0 0,81.0039 -19.451172,-9.18945 0,-71.81445 z m 29.4375005,0 19.3535155,0 -0.002,71.9707 -19.35351562,9.14453 0.001953,-81.11523 z m 94.1835935,21.52539 17.125004,7.00195 -17.125004,8.08984 0,-15.09179 z m -198.080076,0.0254 0,15.08984 -17.12305,-8.08984 17.12305,-7 z m -16.85938,23.80859 108.335941,51.17773 0,155.58985 -108.335941,-51.17774 0,-155.58984 z m 231.75586,0 0,155.58984 -108.335935,51.17774 0,-155.58985 108.335935,-51.17773 z"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
54
mcp/packages/browser-extension/public/popup.css
Normal file
54
mcp/packages/browser-extension/public/popup.css
Normal file
@ -0,0 +1,54 @@
|
||||
: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);
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
li.empty {
|
||||
list-style: none;
|
||||
margin-left: -18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
15
mcp/packages/browser-extension/public/popup.html
Normal file
15
mcp/packages/browser-extension/public/popup.html
Normal file
@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Penpot MCP</title>
|
||||
<link rel="stylesheet" href="popup.css" />
|
||||
</head>
|
||||
<body>
|
||||
<h1>Penpot MCP</h1>
|
||||
<p class="note">The browser does not discard these tabs while they are connected to MCP.</p>
|
||||
<h2>Connected tabs</h2>
|
||||
<ul id="sessions"></ul>
|
||||
<script type="module" src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
18
mcp/packages/browser-extension/scripts/build.mjs
Normal file
18
mcp/packages/browser-extension/scripts/build.mjs
Normal file
@ -0,0 +1,18 @@
|
||||
import { build } from "esbuild";
|
||||
import { cp, rm, writeFile } from "node:fs/promises";
|
||||
import { BROWSERS, createManifest } from "./manifest.mjs";
|
||||
|
||||
const TARGETS = { chrome: "chrome116", firefox: "firefox140" };
|
||||
|
||||
await rm("dist", { recursive: true, force: 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");
|
||||
}
|
||||
61
mcp/packages/browser-extension/scripts/manifest.mjs
Normal file
61
mcp/packages/browser-extension/scripts/manifest.mjs
Normal file
@ -0,0 +1,61 @@
|
||||
/** Browsers the extension is built for, one directory of `dist/` each. */
|
||||
export const BROWSERS = ["chrome", "firefox"];
|
||||
|
||||
const ICONS = {
|
||||
16: "icons/icon-16.png",
|
||||
32: "icons/icon-32.png",
|
||||
48: "icons/icon-48.png",
|
||||
128: "icons/icon-128.png",
|
||||
};
|
||||
|
||||
const CONTENT_SCRIPTS = [
|
||||
{
|
||||
matches: ["https://design.penpot.app/*", "https://*.penpot.dev/*", "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. Firefox runs it as an event page 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",
|
||||
version: "0.1.0",
|
||||
description: "Keeps the browser from discarding Penpot tabs connected to MCP.",
|
||||
icons: ICONS,
|
||||
permissions: ["storage"],
|
||||
action: {
|
||||
default_title: "Penpot MCP",
|
||||
default_popup: "popup.html",
|
||||
default_icon: { 16: ICONS[16], 32: ICONS[32] },
|
||||
},
|
||||
content_scripts: CONTENT_SCRIPTS,
|
||||
};
|
||||
if (browser === "chrome") {
|
||||
return {
|
||||
...common,
|
||||
minimum_chrome_version: "116",
|
||||
background: { service_worker: "background.js", type: "module" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
background: { scripts: ["background.js"] },
|
||||
browser_specific_settings: {
|
||||
gecko: {
|
||||
id: "mcp-extension@penpot.app",
|
||||
// the first version that knows data_collection_permissions
|
||||
strict_min_version: "140.0",
|
||||
data_collection_permissions: { required: ["none"] },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
102
mcp/packages/browser-extension/src/PageBridge.test.ts
Normal file
102
mcp/packages/browser-extension/src/PageBridge.test.ts
Normal file
@ -0,0 +1,102 @@
|
||||
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", 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
|
||||
}
|
||||
}
|
||||
}
|
||||
13
mcp/packages/browser-extension/src/PopupState.test.ts
Normal file
13
mcp/packages/browser-extension/src/PopupState.test.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { getPopupState } from "./PopupState.ts";
|
||||
import { SessionMap } from "./SessionMap.ts";
|
||||
|
||||
test("lists the sessions and their tabs", async () => {
|
||||
const sessions = new SessionMap({ load: async () => undefined, save: async () => {} });
|
||||
await sessions.announce(
|
||||
{ tabId: 7, windowId: 1 },
|
||||
{ type: "penpot-mcp:session", sessionId: "tab", status: "connected" }
|
||||
);
|
||||
assert.deepEqual(getPopupState(sessions), { sessions: [{ sessionId: "tab", tabId: 7 }] });
|
||||
});
|
||||
22
mcp/packages/browser-extension/src/PopupState.ts
Normal file
22
mcp/packages/browser-extension/src/PopupState.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import type { SessionMap } from "./SessionMap.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 {
|
||||
sessions: { sessionId: string; tabId: number }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the state shown in the popup.
|
||||
*
|
||||
* @param sessions - The map of sessions to tabs
|
||||
*/
|
||||
export function getPopupState(sessions: SessionMap): PopupState {
|
||||
return {
|
||||
sessions: sessions.all().map(([sessionId, entry]) => ({ sessionId, tabId: entry.tabId })),
|
||||
};
|
||||
}
|
||||
125
mcp/packages/browser-extension/src/SessionMap.test.ts
Normal file
125
mcp/packages/browser-extension/src/SessionMap.test.ts
Normal file
@ -0,0 +1,125 @@
|
||||
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, status };
|
||||
}
|
||||
|
||||
let store: MemoryStore;
|
||||
let changes: SessionChange[];
|
||||
let sessions: SessionMap;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new MemoryStore();
|
||||
changes = [];
|
||||
sessions = new SessionMap(store);
|
||||
sessions.observe({ sessionsChanged: (change) => changes.push(change) });
|
||||
});
|
||||
|
||||
test("maps a connected session to the announcing tab and saves it", async () => {
|
||||
await sessions.announce(TAB, announcement("connected"));
|
||||
assert.deepEqual(sessions.get("session"), TAB);
|
||||
assert.deepEqual(store.saved, { session: TAB });
|
||||
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);
|
||||
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 });
|
||||
const restored = new SessionMap(saved);
|
||||
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());
|
||||
guarded.observe(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));
|
||||
});
|
||||
129
mcp/packages/browser-extension/src/SessionMap.ts
Normal file
129
mcp/packages/browser-extension/src/SessionMap.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import type { SessionAnnouncement } from "@penpot/mcp-common";
|
||||
|
||||
/** The tab holding an MCP session. */
|
||||
export interface SessionEntry {
|
||||
tabId: number;
|
||||
windowId: number;
|
||||
}
|
||||
|
||||
/** 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 observers: SessionMapObserver[] = [];
|
||||
private readonly loaded: Promise<void>;
|
||||
|
||||
constructor(private readonly store: SessionStore) {
|
||||
this.loaded = store.load().then(
|
||||
(saved) => {
|
||||
this.entries = new Map(Object.entries(saved ?? {}));
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
|
||||
/** Adds a receiver of the changes of this map. */
|
||||
observe(observer: SessionMapObserver): void {
|
||||
this.observers.push(observer);
|
||||
}
|
||||
|
||||
/** 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 } = announcement;
|
||||
await this.change(() => {
|
||||
// a tab holds a single session
|
||||
this.deleteTab(tab.tabId);
|
||||
this.entries.set(sessionId, { ...tab });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
const change = {
|
||||
tabsAdded: [...after].filter((tabId) => !before.has(tabId)),
|
||||
tabsRemoved: [...before].filter((tabId) => !after.has(tabId)),
|
||||
};
|
||||
for (const observer of this.observers) {
|
||||
observer.sessionsChanged(change);
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
44
mcp/packages/browser-extension/src/background.ts
Normal file
44
mcp/packages/browser-extension/src/background.ts
Normal file
@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Service worker of the Penpot MCP extension.
|
||||
*
|
||||
* It keeps the map from MCP sessions to the tabs holding them, and keeps the browser from
|
||||
* discarding those tabs.
|
||||
*
|
||||
* 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, ChromeSessionStore } 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";
|
||||
|
||||
const sessions = new SessionMap(new ChromeSessionStore());
|
||||
sessions.observe(new DiscardGuard(new ChromeDiscardControl()));
|
||||
|
||||
chrome.runtime.onMessage.addListener((message: ContentMessage | PopupStateRequest, sender, sendResponse) => {
|
||||
if (sender.id !== chrome.runtime.id) {
|
||||
return;
|
||||
}
|
||||
const tab = sender.tab;
|
||||
if (message?.type === "popup:get-state" && !tab) {
|
||||
void sessions.ready().then(() => sendResponse(getPopupState(sessions)));
|
||||
// the response is sent asynchronously
|
||||
return true;
|
||||
}
|
||||
if (tab?.id === undefined) {
|
||||
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();
|
||||
45
mcp/packages/browser-extension/src/manifest.test.ts
Normal file
45
mcp/packages/browser-extension/src/manifest.test.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { access } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { BROWSERS, createManifest } from "../scripts/manifest.mjs";
|
||||
|
||||
const manifests = BROWSERS.map((browser) => createManifest(browser));
|
||||
const [chrome, firefox] = manifests;
|
||||
|
||||
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 only storage", () => {
|
||||
for (const manifest of manifests) {
|
||||
assert.equal(manifest.manifest_version, 3);
|
||||
assert.deepEqual(manifest.permissions, ["storage"]);
|
||||
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-extension@penpot.app",
|
||||
strict_min_version: "140.0",
|
||||
data_collection_permissions: { required: ["none"] },
|
||||
},
|
||||
});
|
||||
assert.equal(chrome.browser_specific_settings, undefined);
|
||||
});
|
||||
|
||||
test("references icons and a popup that exist in public/", async () => {
|
||||
for (const manifest of manifests) {
|
||||
const paths = [
|
||||
...Object.values(manifest.icons as Record<string, string>),
|
||||
...Object.values(manifest.action.default_icon as Record<string, string>),
|
||||
manifest.action.default_popup,
|
||||
];
|
||||
for (const path of paths) {
|
||||
await access(new URL(`../public/${path}`, import.meta.url));
|
||||
}
|
||||
}
|
||||
});
|
||||
28
mcp/packages/browser-extension/src/messages.test.ts
Normal file
28
mcp/packages/browser-extension/src/messages.test.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { parseAnnouncement } from "./messages.ts";
|
||||
|
||||
const announcement = { type: "penpot-mcp:session", sessionId: "abcdefghij", status: "connected" };
|
||||
|
||||
test("accepts session announcements, with or without a session ID", () => {
|
||||
for (const data of [announcement, { ...announcement, sessionId: null, status: "connecting" }]) {
|
||||
assert.deepEqual(parseAnnouncement(data), data);
|
||||
}
|
||||
});
|
||||
|
||||
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, status: "frozen" },
|
||||
]) {
|
||||
assert.equal(parseAnnouncement(data), null, JSON.stringify(data));
|
||||
}
|
||||
});
|
||||
32
mcp/packages/browser-extension/src/messages.ts
Normal file
32
mcp/packages/browser-extension/src/messages.ts
Normal file
@ -0,0 +1,32 @@
|
||||
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 isSessionId(value: unknown): value is string | null {
|
||||
return value === null || (typeof value === "string" && value.length > 0 && value.length <= 128);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, status } = data as Record<string, unknown>;
|
||||
if (type !== "penpot-mcp:session" || !isSessionId(sessionId) || !STATUSES.includes(status as McpConnectionStatus)) {
|
||||
return null;
|
||||
}
|
||||
return { type, sessionId, status: status as McpConnectionStatus };
|
||||
}
|
||||
28
mcp/packages/browser-extension/src/popup.ts
Normal file
28
mcp/packages/browser-extension/src/popup.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { PopupState, PopupStateRequest } from "./PopupState.ts";
|
||||
|
||||
const REFRESH_INTERVAL_MS = 1_000;
|
||||
|
||||
const sessionsList = document.getElementById("sessions") as HTMLUListElement;
|
||||
|
||||
function render(state: PopupState): void {
|
||||
const items = state.sessions.map((session) => `${session.sessionId} (tab ${session.tabId})`);
|
||||
sessionsList.replaceChildren(
|
||||
...(items.length ? items : ["No Penpot tab is connected to MCP."]).map((text) => {
|
||||
const item = document.createElement("li");
|
||||
item.textContent = text;
|
||||
item.classList.toggle("empty", items.length === 0);
|
||||
return item;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
void refresh();
|
||||
setInterval(() => void refresh(), REFRESH_INTERVAL_MS);
|
||||
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"]
|
||||
}
|
||||
@ -97,3 +97,36 @@ export interface ExecuteCodeTaskResultData<T> {
|
||||
*/
|
||||
log: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of the Penpot MCP plugin connection, as reported to the Penpot app.
|
||||
*/
|
||||
export type McpConnectionStatus = "idle" | "connecting" | "connected" | "disconnected" | "error";
|
||||
|
||||
/**
|
||||
* Message posted by a browser extension's content script to ask the Penpot page for its MCP session.
|
||||
*/
|
||||
export interface ExtensionHello {
|
||||
type: "penpot-mcp-extension:hello";
|
||||
}
|
||||
|
||||
/**
|
||||
* Message posted by the Penpot page when it starts listening for an {@link ExtensionHello}.
|
||||
*
|
||||
* A content script that said hello before the page was listening says it again.
|
||||
*/
|
||||
export interface PageReady {
|
||||
type: "penpot-mcp:ready";
|
||||
}
|
||||
|
||||
/**
|
||||
* Message posted by the Penpot page to announce its MCP session to a browser extension.
|
||||
*
|
||||
* The page sends it in reply to {@link ExtensionHello} and on every connection status change.
|
||||
*/
|
||||
export interface SessionAnnouncement {
|
||||
type: "penpot-mcp:session";
|
||||
/** the MCP session ID of the page, or null while it is not known. */
|
||||
sessionId: string | null;
|
||||
status: McpConnectionStatus;
|
||||
}
|
||||
|
||||
@ -147,6 +147,17 @@ test("resume and freeze events cannot send liveness messages before initializati
|
||||
assert.deepEqual(ui.sockets[0].sent, [{ type: "heartbeat" }]);
|
||||
});
|
||||
|
||||
test("answers heartbeat requests once initialized, without forwarding them to the plugin", () => {
|
||||
const ui = pluginUi();
|
||||
ui.connect();
|
||||
ui.sockets[0].receive({ type: "heartbeat-request" });
|
||||
assert.deepEqual(ui.sockets[0].sent, []);
|
||||
ui.sockets[0].receive({ type: "initialized" });
|
||||
ui.sockets[0].receive({ type: "heartbeat-request" });
|
||||
assert.deepEqual(ui.sockets[0].sent, [{ type: "heartbeat" }]);
|
||||
assert.equal(ui.messages.filter((message) => message.type === "heartbeat-request").length, 0);
|
||||
});
|
||||
|
||||
test("plugin reads the current file for each connection metadata request", () => {
|
||||
let onMessage!: (message: any) => void;
|
||||
const messages: any[] = [];
|
||||
|
||||
@ -145,7 +145,9 @@ function sendTaskResponse(response: any): void {
|
||||
* Emits a liveness signal from the plugin event loop.
|
||||
*
|
||||
* WebSocket ping/pong is not enough here: browsers can answer protocol pings while
|
||||
* page JavaScript is frozen and unable to run MCP tasks.
|
||||
* page JavaScript is frozen and unable to run MCP tasks. The plugin sends one on a timer and
|
||||
* in answer to each heartbeat request of the server, since a browser runs the timers of a
|
||||
* hidden page as rarely as once a minute but handles socket messages at once.
|
||||
*/
|
||||
function sendHeartbeat(): boolean {
|
||||
if (connectionReady && ws?.readyState === WebSocket.OPEN) {
|
||||
@ -252,6 +254,10 @@ function connectToMcpServer(baseUrl?: string, token?: string): void {
|
||||
parent.postMessage({ type: "context-request" }, "*");
|
||||
return;
|
||||
}
|
||||
if (request.type === "heartbeat-request") {
|
||||
sendHeartbeat();
|
||||
return;
|
||||
}
|
||||
// Track the current task received from the MCP server
|
||||
if (request.task) {
|
||||
updateCurrentTask(request.task);
|
||||
|
||||
@ -17,6 +17,8 @@ export interface PenpotConnection extends PluginLivenessState {
|
||||
readonly userToken: string | null;
|
||||
readonly session: PenpotSession;
|
||||
readonly pingInterval: NodeJS.Timeout;
|
||||
/** the interval sending heartbeat requests to the plugin. */
|
||||
readonly heartbeatRequestInterval: NodeJS.Timeout;
|
||||
/** whether initialization and dispatch subscriptions have completed. */
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { once } from "node:events";
|
||||
import { afterEach, beforeEach, test } from "node:test";
|
||||
import { afterEach, beforeEach, mock, test } from "node:test";
|
||||
import { WebSocket } from "ws";
|
||||
import { PluginBridge } from "./PluginBridge";
|
||||
import { HEARTBEAT_REQUEST_INTERVAL_MS, PluginBridge } from "./PluginBridge";
|
||||
import { PluginTask } from "./PluginTask";
|
||||
import type { PenpotMcpServer } from "./PenpotMcpServer";
|
||||
|
||||
@ -37,17 +37,33 @@ async function connect(sessionId: string, token = "alice", fileName = "Design",
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${port}?userToken=${token}`);
|
||||
sockets.push(socket);
|
||||
await once(socket, "open");
|
||||
// answer tasks from the start, since one may arrive right behind the initialization
|
||||
socket.on("message", (raw) => {
|
||||
const request = JSON.parse(raw.toString());
|
||||
if (request.type !== "initialized" && request.type !== "heartbeat-request") {
|
||||
socket.send(JSON.stringify({ id: request.id, success: true, data: reply }));
|
||||
}
|
||||
});
|
||||
const initialized = once(socket, "message", { signal: AbortSignal.timeout(1500) });
|
||||
socket.send(JSON.stringify({ type: "initialize", session: { sessionId, fileId: "file-1", fileName } }));
|
||||
const [message] = await initialized;
|
||||
assert.equal(JSON.parse(message.toString()).type, "initialized");
|
||||
socket.on("message", (raw) => {
|
||||
const request = JSON.parse(raw.toString());
|
||||
socket.send(JSON.stringify({ id: request.id, success: true, data: reply }));
|
||||
});
|
||||
return socket;
|
||||
}
|
||||
|
||||
test("asks each plugin for a heartbeat at a fixed interval", async () => {
|
||||
mock.timers.enable({ apis: ["setInterval"] });
|
||||
try {
|
||||
const socket = await connect("tab-1");
|
||||
const requested = once(socket, "message", { signal: AbortSignal.timeout(1500) });
|
||||
mock.timers.tick(HEARTBEAT_REQUEST_INTERVAL_MS);
|
||||
const [message] = await requested;
|
||||
assert.deepEqual(JSON.parse(message.toString()), { type: "heartbeat-request" });
|
||||
} finally {
|
||||
mock.timers.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps two tabs for the same file and dispatches to the selected session", async () => {
|
||||
await connect("tab-1");
|
||||
await connect("tab-2");
|
||||
@ -169,3 +185,21 @@ test("a dashboard session remains routable while its workspace metadata changes"
|
||||
assert.deepEqual(bridge.getUserConnections("alice")!.getSessions(), [{ sessionId: "tab", ...context }]);
|
||||
}
|
||||
});
|
||||
|
||||
test("a task fails at once when its plugin connection closes before answering", async () => {
|
||||
const plugin = new WebSocket(`ws://127.0.0.1:${port}?userToken=alice`);
|
||||
sockets.push(plugin);
|
||||
await once(plugin, "open");
|
||||
const initialized = once(plugin, "message");
|
||||
plugin.send(JSON.stringify({ type: "initialize", session: { sessionId: "tab", fileId: null, fileName: null } }));
|
||||
await initialized;
|
||||
// the socket closes once the task reaches it
|
||||
plugin.on("message", () => plugin.terminate());
|
||||
const started = Date.now();
|
||||
await assert.rejects(
|
||||
bridge.executePluginTask(new PluginTask("test", {}), "tab"),
|
||||
/connection closed before the task finished/
|
||||
);
|
||||
// well within the 1s task timeout of these tests
|
||||
assert.ok(Date.now() - started < 500);
|
||||
});
|
||||
|
||||
@ -15,6 +15,14 @@ import { MultiInstanceTaskDispatcher } from "./dispatch/MultiInstanceTaskDispatc
|
||||
const KEEP_ALIVE_TIME = 30000; // 30 seconds
|
||||
const INITIALIZATION_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* The interval, in ms, between the heartbeat requests sent to each plugin.
|
||||
*
|
||||
* A browser runs the timers of a hidden page as rarely as once a minute, which would leave the
|
||||
* plugin's own heartbeat older than the stale threshold, but it handles socket messages at once.
|
||||
*/
|
||||
export const HEARTBEAT_REQUEST_INTERVAL_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Maximum plugin heartbeat age before a connection is stale.
|
||||
*
|
||||
@ -187,8 +195,8 @@ export class PluginBridge implements TaskDispatchHost {
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
this.logger.info("WebSocket connection closed");
|
||||
ws.on("close", (code: number, reason: Buffer) => {
|
||||
this.logger.info("WebSocket connection closed (code %d%s)", code, reason.length ? `: ${reason}` : "");
|
||||
clearTimeout(initializationTimeout);
|
||||
this.removeConnection(ws);
|
||||
});
|
||||
@ -218,12 +226,17 @@ export class PluginBridge implements TaskDispatchHost {
|
||||
const pingInterval = setInterval(() => {
|
||||
ws.ping();
|
||||
}, KEEP_ALIVE_TIME);
|
||||
// the plugin answers with a heartbeat, which a frozen page cannot send
|
||||
const heartbeatRequestInterval = setInterval(() => {
|
||||
ws.send(JSON.stringify({ type: "heartbeat-request" }));
|
||||
}, HEARTBEAT_REQUEST_INTERVAL_MS);
|
||||
|
||||
// register the client connection with both indexes
|
||||
const connection: PenpotConnection = {
|
||||
socket: ws,
|
||||
userToken,
|
||||
pingInterval,
|
||||
heartbeatRequestInterval,
|
||||
session,
|
||||
ready: false,
|
||||
lastHeartbeat: Date.now(),
|
||||
@ -260,10 +273,11 @@ export class PluginBridge implements TaskDispatchHost {
|
||||
/**
|
||||
* Removes a client connection and releases all resources associated with it.
|
||||
*
|
||||
* Clears the per-connection keep-alive interval and removes the connection from the
|
||||
* socket-keyed index and the user's session collection. The token-keyed index entry
|
||||
* is removed only when the user's last local connection closes. Safe to call with a
|
||||
* socket that is not (or no longer) registered.
|
||||
* Clears the per-connection keep-alive and heartbeat-request intervals and removes the
|
||||
* connection from the socket-keyed index and the user's session collection. The
|
||||
* token-keyed index entry is removed only when the user's last local connection closes.
|
||||
* Tasks sent over the connection and still awaiting a response fail at once. Safe to call
|
||||
* with a socket that is not (or no longer) registered.
|
||||
*
|
||||
* @param ws - The WebSocket whose connection state should be removed
|
||||
*/
|
||||
@ -274,9 +288,17 @@ export class PluginBridge implements TaskDispatchHost {
|
||||
}
|
||||
connection.ready = false;
|
||||
clearInterval(connection.pingInterval);
|
||||
clearInterval(connection.heartbeatRequestInterval);
|
||||
this.connectionsBySocket.delete(ws);
|
||||
const connections = this.getUserConnections(connection.userToken)!;
|
||||
connections.remove(connection);
|
||||
// a task sent over the closed socket can no longer be answered, since responses only count
|
||||
// from the connection the task went to
|
||||
for (const [taskId, taskConnection] of [...this.taskConnections]) {
|
||||
if (taskConnection === connection) {
|
||||
this.rejectPendingTask(taskId, new Error("The plugin connection closed before the task finished."));
|
||||
}
|
||||
}
|
||||
if (connection.userToken !== null && connections.size === 0) {
|
||||
this.userPenpotConnectionsByToken.delete(connection.userToken);
|
||||
}
|
||||
|
||||
30
mcp/pnpm-lock.yaml
generated
30
mcp/pnpm-lock.yaml
generated
@ -177,6 +177,24 @@ importers:
|
||||
specifier: ^3.9.10
|
||||
version: 3.9.10
|
||||
|
||||
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==}
|
||||
|
||||
@ -1918,6 +1942,10 @@ snapshots:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 26.6.5
|
||||
|
||||
'@types/chrome@0.3.6':
|
||||
dependencies:
|
||||
'@types/har-format': 1.2.16
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 26.6.5
|
||||
@ -1935,6 +1963,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