✨ 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.
This commit is contained in:
alonso.torres 2026-10-30 09:11:49 +00:00
parent ab10d8ca93
commit 98a0163d25
4 changed files with 101 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,35 @@
(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 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 +181,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 +205,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 +228,15 @@
(rx/take 1)
(rx/subs! (fn [_]
(reset! active? false)
(.removeEventListener globals/window "message" on-message)
(when @announce?
(announce-session "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"}}

View File

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