mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
✨ 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:
parent
b132939b71
commit
61d0f2be62
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
})();
|
||||
|
||||
@ -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"}}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user