✨ Announce the MCP session to a browser extension

Let a browser extension learn which tab holds an MCP session, so it can
wake that tab when the browser freezes it. 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, MCP
server URL, token 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, so the token stays in the page when no extension is
installed. It reaches the window through app.util.globals, whose
stand-in for the unit tests accepts postMessage.
This commit is contained in:
alonso.torres 2026-10-26 10:29:07 +00:00
parent b132939b71
commit 61d0f2be62
3 changed files with 72 additions and 0 deletions

View File

@ -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]
@ -135,6 +136,37 @@
(effect [_ _ _]
(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 uses it
to wake this tab when the browser freezes it."
[token status session-id]
(.postMessage globals/window
#js {:type "penpot-mcp:session"
:sessionId session-id
:serverUrl (str cf/mcp-ws-uri)
:token token
:status status}
(.-origin globals/location)))
(defn- init-mcp-plugin
"Waits the plugin runtime and initializes the bundled MCP plugin."
[{:keys [token]}]
@ -151,6 +183,20 @@
(rx/filter (ptk/type? ::init) stream))
active? (atom true)
;; Whether a browser extension asked for the session; the token
;; is only posted on the page after 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 token
(extension-status connection-status connection-requested)
session-id))))
extension #js {:getToken (constantly token)
:getServerUrl #(str cf/mcp-ws-uri)
:isConnectionRequested #(and @active?
@ -162,6 +208,10 @@
(get-in @st/state [:mcp :connection-requested]))
(start-reconnect-watcher))
(st/emit! (update-mcp-connection-status status session-id))
(when @announce?
(announce-session token
(extension-status status (get-in @st/state [:mcp :connection-requested]))
session-id))
(log/info :hint "MCP STATUS" :status status)))
:on
@ -182,8 +232,15 @@
(rx/take 1)
(rx/subs! (fn [_]
(reset! active? false)
(.removeEventListener globals/window "message" on-message)
(when @announce?
(announce-session token "disconnected" nil))
(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

View File

@ -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;
}
})();

View File

@ -58,6 +58,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"}}