diff --git a/.serena/memories/mcp/core.md b/.serena/memories/mcp/core.md index 9d55a13105..feb218b5d3 100644 --- a/.serena/memories/mcp/core.md +++ b/.serena/memories/mcp/core.md @@ -98,5 +98,6 @@ For parallel devenvs, prefer same-origin MCP routing: each Penpot instance shoul ## Plugin reconnect policy - The plugin treats WebSocket close code `1008` (policy violation) as terminal: it stops auto-reconnecting and stays disconnected until the user explicitly reconnects. Other close codes keep the capped-backoff retry. The decision lives in `ReconnectPolicy.ts` (`shouldReconnectAfterClose`), kept as a pure module so it is unit-testable without DOM/CSS. -- The MCP server emits `1008` for a duplicate connection on the same user token (`PluginBridge`) and for a missing `userToken` in multi-user mode. -- A tab rejected with `1008` never reaches `connected`, so the frontend's 60s reconnect watcher (`start-reconnect-watcher` in `app.main.data.workspace.mcp`, started only on `connected`) does not engage; recovery is manual via "Connect here". +- The MCP server emits `1008` for a missing `userToken` in multi-user mode, for missing/invalid connection metadata, and to close a connection displaced by a newer one with the same user and session ID. +- Session IDs are deterministic per tab and file, so a reconnect reuses its ID while the old socket may be half-open: the newest connection wins (`PluginBridge.displaceConnection`). Across instances, each new connection publishes a session claim on a per-user Redis channel; an instance displaces its local connection only on a foreign claim that arrives after its own claim (channel order, no clocks). Discovery deduplicates by session ID. +- A tab rejected with `1008` never reaches `connected`, so the frontend's 60s reconnect watcher (`start-reconnect-watcher` in `app.main.data.workspace.mcp`, started only on `connected`) does not engage; recovery is manual via the MCP menu. diff --git a/docs/mcp/index.md b/docs/mcp/index.md index 3f11995d2b..815a468fea 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -64,7 +64,7 @@ Watch more applications in the **[Penpot MCP video playlist](https://www.youtube There are three key pieces: * **MCP server**: a service that exposes tools to your AI client. It receives requests from the client and forwards them to Penpot. -* **MCP plugin in Penpot**: a plugin that runs inside Penpot and connects your open file to the MCP server. It is what allows the server to access the currently focused page. +* **MCP plugin in Penpot**: a plugin that runs inside Penpot and connects your open file to the MCP server. It allows the server to access that file and its pages. * **MCP client**: the tool where you write prompts (Cursor, Claude Code, Copilot-style tools, etc.). It connects to the MCP server using a server URL and an MCP key (or your active Penpot session in the current local setup).  @@ -74,8 +74,23 @@ There are three key pieces: Some important concepts for users: * **Integrations page**: MCP is configured under **Your account → Integrations → MCP Server**. Here you enable or disable MCP, get the server URL and manage the MCP key. * **MCP key**: a personal, non-recoverable token that authenticates your AI client with the MCP server. Only one key can exist per user at a time. This is used by the remote MCP setup. -* **Currently focused page**: MCP always operates on the page you have in focus in Penpot. If you change the focused page (even in another browser window), the MCP context follows that page. -* **Active MCP tab**: MCP can only be active in one browser tab at a time. If you have Penpot open in several tabs, you choose explicitly which one owns MCP before running agents. +* **MCP session**: a connection from a Penpot tab to the MCP server, identified by a short session ID. Several tabs can connect independently, including tabs showing the same file. +* **Current page**: the page open in the selected session. Switching browser focus to another tab does not change which session the agent uses. + +### Working with multiple files + +Connect the tabs you want to use: + +* **Remote MCP**: open each file and choose **MCP → Connect** in the toolbar. Use **Disconnect** in the same menu to disconnect that tab. +* **Local MCP**: run the plugin in each file and connect it to your local server. Each plugin connection has its own session ID. +* Connecting or disconnecting one tab does not disconnect the others. + +Choose a session for your agent: + +* With exactly one connected session, the server selects it automatically when the agent does not specify a session ID. +* With several connected sessions, the server lists them and asks the agent to have you select one. The agent then passes that session ID with its requests. +* You can also give the agent an ID directly. For remote MCP, open the toolbar's **MCP** menu and choose **Copy session ID**. For local MCP, use **Copy** beside the session ID in the plugin window. +* For example: "Use Penpot session `o37vgcqsvt` and list the pages in that file." ### Tools and capabilities @@ -98,7 +113,7 @@ Because **remote MCP** does not expose local file-system access: ### Agents can edit designs -**Be mindful:** when MCP is connected, your AI client can run **write operations** that change the currently focused Penpot page (create, rename, move, delete, restyle, etc.). To stay safe: +**Be mindful:** when MCP is connected, your AI client can run **write operations** that change the file in the selected session (create, rename, move, delete, restyle, etc.). To stay safe: * Start with **read-only** actions (inspect, list, export) to verify your setup. * Ask the agent to **describe the intended changes** before applying them. @@ -142,7 +157,7 @@ If you just want to try Penpot AI workflows quickly through the MCP, follow this See the section **Connect your MCP client** for more details on how to connect. 5. #### Open a Penpot file and connect MCP - In Penpot, open a design file and use **File → MCP Server → Connect** to connect the plugin to your current file. + In Penpot, open a design file and choose **MCP → Connect** in the toolbar. Enabling MCP does not connect a file automatically.  @@ -156,9 +171,12 @@ When this happens, MCP fails fast instead of waiting for a long task timeout: * In Chrome and Chromium-based browsers, the plugin can report when the tab is being frozen. * In Firefox, Safari, and other browsers that do not expose the same freeze event, MCP uses plugin heartbeats. If the browser stops running the plugin JavaScript, the heartbeat becomes stale and the MCP server reports that the Penpot tab appears to be suspended. -* If the browser unloads the tab completely, the plugin disconnects and MCP reports that no Penpot plugin instance is connected. +* If the browser unloads the tab completely, its plugin disconnects and that session is no longer available. Other connected sessions remain available. -To recover, open or focus the Penpot tab again, wait until MCP reconnects, and retry the prompt. +To recover: + +* If the tab was only suspended, focus it, wait until MCP reconnects, and retry the prompt. +* If the tab was unloaded or reloaded, open the file and connect again. Copy the new session ID if your agent was using the previous one. To reduce the chances of the browser putting Penpot to sleep during long MCP sessions: @@ -239,7 +257,7 @@ Note: For clients that do not support HTTP servers directly (like Claude Desktop ### Final check -In Penpot, open a file and connect the plugin from **File → MCP Server → Connect**, then run a read-only prompt first. +In Penpot, open a file and choose **MCP → Connect** in the toolbar, then run a read-only prompt first. *** @@ -253,7 +271,7 @@ Remote MCP is the easiest way to start using AI agents with Penpot. It's hosted 1. Open **Your account → Integrations**. 2. In the **MCP Server** section, read the short description to confirm that feature is available for your account. -3. Use the **Status** toggle to enable MCP Server. Penpot remembers this state per user across sessions. +3. Use the **Status** toggle to enable MCP Server. Penpot remembers this state per user across sessions. You still need to connect each file explicitly. 4. If this is your first time, Penpot will ask you to **generate an MCP key**. The key is shown only once, store it safely. * Treat the MCP key like a password/token: do not share it in screenshots, logs, or code samples. 5. Once enabled, you will see: @@ -309,11 +327,11 @@ Once everything is configured, day-to-day use of Penpot MCP follows a simple pat 1. **Enable MCP** * Go to **Your account → Integrations → MCP Server** and set **Status** to **Enabled**. 2. **Connect plugin**: - * Open a design file and use **File → MCP Server → Connect**. + * Open a design file and choose **MCP → Connect** in the toolbar. Repeat in each tab you want the agent to access. 3. **Run prompts**: * Open your MCP client and start with read-only prompts first (`list`, `inspect`, `analyze`), then continue with write actions. -MCP always acts on the **currently focused page** in the active Penpot tab. +Requests target the selected MCP session. See [Working with multiple files](#working-with-multiple-files) to choose a session. #### Manage @@ -343,7 +361,7 @@ Security recommendations to highlight in the Help Center: * Treat your MCP key like a password or access token, do not share it in screenshots or code samples. * Regenerate the key if you suspect it may have leaked. -* Remember that disabling MCP Server or disconnecting the plugin stops agents from modifying your files, even if a client is still configured. +* Disabling MCP Server disconnects your integrated sessions. Disconnecting one tab stops access through that connection; other connected sessions remain available. *** @@ -375,7 +393,7 @@ Leave this terminal running while you use MCP. 5. Run the plugin and click **Connect to MCP server**. -6. Make sure the plugin shows **Connected** and keep the plugin window open while working with AI agents. +6. Make sure the plugin shows **Connected** and keep the plugin window open while working with AI agents. The plugin displays the session ID and a **Copy** button. > Some Chromium-based browsers may block the connection from `https://design.penpot.app` to `http://localhost`. If that happens, explicitly allow local network access or use a browser like Firefox. @@ -420,7 +438,7 @@ Once everything is configured, day-to-day use of Penpot MCP follows a simple pat Open your MCP client and start with read-only prompts first (`list`, `inspect`, `analyze`), then continue with write actions. -MCP always acts on the **currently focused page** in the active Penpot tab. +Requests target the selected MCP session. See [Working with multiple files](#working-with-multiple-files) to choose a session. #### Manage diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index bc7ef868eb..74884289c4 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -37,7 +37,8 @@ :description "This plugin enables interaction with the Penpot MCP server" :allow-background true :permissions - #{"library:read" "library:write" + #{"user:read" + "library:read" "library:write" "comment:read" "comment:write" "content:write" "content:read"}}) @@ -47,10 +48,20 @@ (defn connect-mcp [] (ptk/reify ::connect-mcp + ptk/UpdateEvent + (update [_ state] + (if (and (get-in state [:mcp :enabled]) + (get-in state [:mcp :token-valid])) + (update state :mcp assoc + :connection-requested true + :connection-status "connecting") + state)) + ptk/WatchEvent - (watch [_ _ _] - (rx/of (mbc/event :mcp/force-disconnect {}) - (ptk/data-event ::connect))))) + (watch [_ state _] + (if (get-in state [:mcp :connection-requested]) + (rx/of (ptk/data-event ::connect)) + (rx/empty))))) (defn- start-reconnect-watcher [] @@ -63,8 +74,9 @@ ;; Slow app-level fallback. The plugin owns normal WebSocket ;; reconnects; this only restarts it if the app remains in a ;; failed connection state. - (when (contains? reconnect-fallback-statuses - (-> @st/state :mcp :connection-status)) + (when (and (get-in @st/state [:mcp :connection-requested]) + (contains? reconnect-fallback-statuses + (-> @st/state :mcp :connection-status))) (.log js/console "Reconnecting to MCP...") (st/emit! (ptk/data-event ::connect)))))))) @@ -74,6 +86,8 @@ (rx/dispose! @interval-sub) (reset! interval-sub nil))) +(declare user-disconnect-mcp) + ;; This event will arrive when the mcp is enabled in the dashboard (defn update-mcp-status [value] @@ -86,37 +100,37 @@ ptk/WatchEvent (watch [_ _ _] - (case value - true (rx/of (connect-mcp)) - false (rx/of (ptk/data-event ::disconnect)) - nil)))) + (if (false? value) + (rx/of (user-disconnect-mcp)) + (rx/empty))))) (defn update-mcp-connection-status - [value] - (ptk/reify ::update-mcp-plugin-connection - ptk/UpdateEvent - (update [_ state] - (update state :mcp assoc :connection-status value)) - - ptk/WatchEvent - (watch [_ _ _] - ;; Only one MCP plugin instance may be active across browser tabs. - ;; When this tab becomes connected, tell every other tab to - ;; disconnect (which also stops their reconnect watcher). Otherwise - ;; several tabs stay connected at once and the MCP server reports - ;; "multiple instances connected" and the agent fails. - (when (= "connected" value) - (rx/of (mbc/event :mcp/force-disconnect {})))))) + ([value] + (update-mcp-connection-status value nil)) + ([value session-id] + (ptk/reify ::update-mcp-plugin-connection + ptk/UpdateEvent + (update [_ state] + (if (get-in state [:mcp :connection-requested]) + (update state :mcp assoc + :connection-status value + :session-id session-id) + state))))) ;; This event will arrive when the user selects disconnect on the menu -;; or there is a broadcast message for disconnection (defn user-disconnect-mcp [] (ptk/reify ::user-disconnect-mcp + ptk/UpdateEvent + (update [_ state] + (update state :mcp assoc + :connection-requested false + :connection-status "disconnected" + :session-id nil)) + ptk/WatchEvent (watch [_ _ _] - (rx/of (ptk/data-event ::disconnect) - (update-mcp-connection-status "disconnected"))) + (rx/of (ptk/data-event ::disconnect))) ptk/EffectEvent (effect [_ _ _] @@ -134,30 +148,43 @@ stopper-s (rx/merge (rx/filter (ptk/type? ::dw/finalize-workspace) stream) - (rx/filter (ptk/type? ::stop-mcp-plugin) stream)) + (rx/filter (ptk/type? ::stop-mcp-plugin) stream) + (rx/filter (ptk/type? ::init) stream)) + active? (atom true) extension #js {:getToken (constantly token) :getServerUrl #(str cf/mcp-ws-uri) + :isConnectionRequested #(and @active? + (get-in @st/state [:mcp :connection-requested])) :setMcpStatus - (fn [status] - (when (= status "connected") - (start-reconnect-watcher)) - (st/emit! (update-mcp-connection-status status)) - (log/info :hint "MCP STATUS" :status status)) + (fn [status session-id] + (when @active? + (when (and (= status "connected") + (get-in @st/state [:mcp :connection-requested])) + (start-reconnect-watcher)) + (st/emit! (update-mcp-connection-status status session-id)) + (log/info :hint "MCP STATUS" :status status))) :on (fn [event cb] (when-let [event - (case event - "disconnect" ::disconnect - "connect" ::connect - nil)] + (when @active? + (case event + "disconnect" ::disconnect + "connect" ::connect + nil))] (->> stream (rx/filter (ptk/type? event)) (rx/take-until stopper-s) (rx/subs! (fn [_] (cb))))))}] + (->> stopper-s + (rx/take 1) + (rx/subs! (fn [_] + (reset! active? false) + (stop-reconnect-watcher!) + (dp/close-plugin! default-manifest)))) (dp/start-plugin! manifest #js {:mcp extension}))))) (defn- stop-mcp-plugin @@ -198,7 +225,11 @@ (update [_ state] (let [profile (get state :profile) mcp-enabled? (-> profile :props :mcp-enabled boolean)] - (update state :mcp assoc :enabled mcp-enabled?))) + (update state :mcp assoc + :enabled mcp-enabled? + :connection-requested false + :connection-status "disconnected" + :session-id nil))) ptk/WatchEvent (watch [_ state stream] @@ -206,7 +237,6 @@ (rx/filter (ptk/type? ::dw/finalize-workspace) stream) (rx/filter (ptk/type? ::init) stream)) - session-id (get state :session-id) mcp-state (get state :mcp)] (->> (rx/merge @@ -229,19 +259,10 @@ (rx/map init-mcp-plugin)) (rx/empty)) - (->> mbc/stream - (rx/filter (mbc/type? :mcp/force-disconnect)) - (rx/filter (fn [{:keys [id]}] - (not= session-id id))) - (rx/map deref) - (rx/map (fn [] (user-disconnect-mcp)))) - (->> mbc/stream (rx/filter (mbc/type? :mcp/enable)) (rx/mapcat (fn [_] - ;; Re-init so the force-disconnect - ;; listener is set up now that MCP - ;; is enabled. + ;; initialize the idle plugin now that MCP is enabled (rx/of (update-mcp-status true) (init))))) diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.cljs b/frontend/src/app/main/ui/workspace/top_toolbar.cljs index f3d843000d..0a10cf095d 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.cljs +++ b/frontend/src/app/main/ui/workspace/top_toolbar.cljs @@ -12,6 +12,7 @@ [app.config :as cf] [app.main.data.event :as ev] [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] [app.main.data.workspace :as dw] [app.main.data.workspace.common :as dwc] [app.main.data.workspace.drawing.common :as dwdc] @@ -28,6 +29,7 @@ [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :as i] + [app.util.clipboard :as clipboard] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [app.util.keyboard :as kbd] @@ -298,29 +300,83 @@ (mf/defc mcp-tool* {::mf/private true ::mf/wrap [mf/memo]} - [{:keys [is-mcp-connected]}] - (let [menu-open* (mf/use-state false) + [{:keys [is-mcp-connected is-connection-requested session-id]}] + (let [copied-text (tr "workspace.toolbar.mcp-session-copied") + copy-error (tr "errors.clipboard-api-unavailable") + menu-open* (mf/use-state false) menu-open? (deref menu-open*) - on-toggle-menu + open-timer* (mf/use-ref nil) + close-timer* (mf/use-ref nil) + + on-open-menu (mf/use-fn (fn [event] (dom/stop-propagation event) - (swap! menu-open* not))) + (cancel-timer! open-timer*) + (cancel-timer! close-timer*) + (reset! menu-open* true))) on-close-menu (mf/use-fn - #(reset! menu-open* false)) + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*) + (reset! menu-open* false))) + + on-display-menu + (mf/use-fn + (fn [] + (cancel-timer! close-timer*) + (cancel-timer! open-timer*) + (mf/set-ref-val! + open-timer* + (ts/schedule 350 + #(do + (reset! menu-open* true) + (mf/set-ref-val! open-timer* nil)))))) + + on-hide-menu + (mf/use-fn + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*) + (mf/set-ref-val! + close-timer* + (ts/schedule 350 + #(do + (reset! menu-open* false) + (mf/set-ref-val! close-timer* nil)))))) on-connect (mf/use-fn #(st/emit! (mcp/connect-mcp) (ev/event {::ev/name "connect-mcp-plugin" - ::ev/origin "workspace:toolbar"})))] + ::ev/origin "workspace:toolbar"}))) - [:* + on-disconnect + (mf/use-fn + #(st/emit! (mcp/user-disconnect-mcp))) + + on-copy-session + (mf/use-fn + (mf/deps session-id copied-text copy-error) + (fn [] + (-> (clipboard/to-clipboard session-id) + (.then #(st/emit! (ntf/info copied-text))) + (.catch #(st/emit! (ntf/error copy-error))))))] + + (mf/with-effect [] + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*))) + + [:div {:on-pointer-enter on-display-menu + :on-pointer-leave on-hide-menu} [:> button* {:variant "ghost" - :on-click on-toggle-menu + :on-click on-open-menu + :aria-haspopup true + :aria-expanded menu-open? :aria-pressed menu-open? :data-tool "mcp" :data-testid "mcp-btn"} @@ -336,13 +392,22 @@ [:> dropdown-menu* {:show menu-open? :on-close on-close-menu :class (stl/css :toolbar-mcp-dropdown)} - (if is-mcp-connected + (when (or is-mcp-connected session-id) [:li {:class (stl/css :toolbar-mcp-dropdown-info) :role "presentation"} - (tr "workspace.toolbar.mcp-connected")] + (when is-mcp-connected + [:span (tr "workspace.toolbar.mcp-connected")]) + (when session-id + [:span (tr "workspace.toolbar.mcp-session-id" session-id)])]) + (when session-id [:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item) - :on-click on-connect} - (tr "workspace.toolbar.mcp-connect-here")])]]])) + :on-click on-copy-session} + (tr "workspace.toolbar.mcp-copy-session-id")]) + [:> dropdown-menu-item* {:class (stl/css :toolbar-mcp-dropdown-item) + :on-click (if is-connection-requested on-disconnect on-connect)} + (if is-connection-requested + (tr "workspace.header.menu.mcp.plugin.status.disconnect") + (tr "workspace.header.menu.mcp.plugin.status.connect"))]]]])) (mf/defc top-toolbar* {::mf/wrap [mf/memo]} @@ -480,7 +545,9 @@ (when mcp-show? [:li {:class (stl/css :toolbar-option)} - [:> mcp-tool* {:is-mcp-connected mcp-connected?}]])] + [:> mcp-tool* {:is-mcp-connected mcp-connected? + :is-connection-requested (:connection-requested mcp) + :session-id (:session-id mcp)}]])] [:button {:title (tr "workspace.toolbar.toggle-toolbar") :aria-label (tr "workspace.toolbar.toggle-toolbar") diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.scss b/frontend/src/app/main/ui/workspace/top_toolbar.scss index 23581afc99..7f5637c8f5 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.scss +++ b/frontend/src/app/main/ui/workspace/top_toolbar.scss @@ -83,7 +83,7 @@ inset-inline-start: 50%; transform: translateX(-50%); margin-block-start: var(--sp-xxs); - padding: var(--sp-xxs) 0; + padding: var(--sp-xxs); border-radius: $br-8; border: #{$b-1} solid var(--menu-border-color); background-color: var(--menu-background-color); @@ -158,21 +158,35 @@ top: $sz-36; } +// Same look as the workspace main menu (`.base-menu` in main_menu.scss). +// The toolbar overrides `--menu-background-color`, so restore the global +// menu value here. .toolbar-mcp-dropdown { - box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark); + --menu-background-color: var(--color-background-tertiary); + + display: flex; + flex-direction: column; + gap: var(--sp-xs); z-index: var(--z-index-dropdown); margin: 0; padding: var(--sp-xs); - border: $b-1 solid var(--panel-border-color); + border: $b-2 solid var(--panel-border-color); border-radius: $br-8; background-color: var(--menu-background-color); + box-shadow: 0 0 $sz-12 0 var(--menu-shadow-color); } .toolbar-mcp-dropdown-info { @include t.use-typography("body-small"); + display: flex; + flex-direction: column; + gap: var(--sp-xs); padding: var(--sp-s) var(--sp-m); + border-radius: $br-8; color: var(--color-foreground-secondary); + background-color: var(--color-background-primary); + font-size: px2rem(11); white-space: nowrap; } @@ -184,10 +198,12 @@ padding: var(--sp-s) var(--sp-m); border-radius: $br-8; color: var(--menu-foreground-color); + background-color: var(--menu-background-color); white-space: nowrap; &:hover { - background-color: var(--menu-background-color-hover); + --menu-foreground-color: var(--menu-foreground-color-hover); + --menu-background-color: var(--menu-background-color-hover); } } diff --git a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs index 76b8fbd948..8bd36d07aa 100644 --- a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs @@ -8,9 +8,12 @@ (:require [app.common.time :as ct] [app.common.uuid :as uuid] + [app.main.data.plugins :as dp] [app.main.data.profile :as du] [app.main.data.workspace.mcp :as mcp] + [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as a] [potok.v2.core :as ptk])) (t/deftest test-update-mcp-status @@ -28,12 +31,12 @@ (t/deftest test-update-mcp-connection-status (t/testing "sets connection status to connected" - (let [state {:mcp {:connection-status "disconnected"}} + (let [state {:mcp {:connection-requested true :connection-status "disconnected"}} result (ptk/update (mcp/update-mcp-connection-status "connected") state)] (t/is (= "connected" (get-in result [:mcp :connection-status]))))) (t/testing "sets connection status to disconnected" - (let [state {:mcp {:connection-status "connected"}} + (let [state {:mcp {:connection-requested true :connection-status "connected"}} result (ptk/update (mcp/update-mcp-connection-status "disconnected") state)] (t/is (= "disconnected" (get-in result [:mcp :connection-status])))))) @@ -123,3 +126,63 @@ event (du/delete-access-token {:id (uuid/next)}) result (ptk/update event state)] (t/is (= 2 (count (:access-tokens result)))))))) + +(t/deftest ^:async test-enable-does-not-connect + (let [events (atom [])] + (await (a/observe (ptk/watch (mcp/update-mcp-status true) {} (rx/empty)) + :on-next #(swap! events conj %))) + (t/is (empty? @events)))) + +(t/deftest ^:async test-connect-is-local + (let [event (mcp/connect-mcp) + state (ptk/update event {:mcp {:enabled true :token-valid true}}) + events (atom [])] + (t/is (true? (get-in state [:mcp :connection-requested]))) + (await (a/observe (ptk/watch event state (rx/empty)) + :on-next #(swap! events conj (ptk/type %)))) + (t/is (= [:app.main.data.workspace.mcp/connect] @events)))) + +(t/deftest test-disconnect-clears-connection-intent + (let [state {:mcp {:connection-requested true + :connection-status "connecting" + :session-id "pq3gxqddgj"}} + result (ptk/update (mcp/user-disconnect-mcp) state)] + (t/is (false? (get-in result [:mcp :connection-requested]))) + (t/is (= "disconnected" (get-in result [:mcp :connection-status]))) + (t/is (nil? (get-in result [:mcp :session-id]))))) + +(t/deftest test-init-clears-previous-file-connection + (let [state {:profile {:props {:mcp-enabled true}} + :mcp {:connection-requested true + :connection-status "connected" + :session-id "pq3gxqddgj"}} + result (ptk/update (mcp/init) state)] + (t/is (false? (get-in result [:mcp :connection-requested]))) + (t/is (= "disconnected" (get-in result [:mcp :connection-status]))) + (t/is (nil? (get-in result [:mcp :session-id]))))) + +(t/deftest test-late-status-cannot-reconnect + (let [state {:mcp {:connection-requested false :connection-status "disconnected"}}] + (t/is (= state (ptk/update (mcp/update-mcp-connection-status "connected") state))))) + +(t/deftest test-plugin-callbacks-stop-with-workspace + (doseq [stop-event [:app.main.data.workspace/finalize-workspace + :app.main.data.workspace.mcp/init]] + (let [stream (rx/subject) + extension (atom nil) + calls (atom 0) + closed (atom 0)] + (with-redefs [dp/start-plugin! (fn [_ extensions] + (reset! extension (.-mcp extensions))) + dp/close-plugin! (fn [_] (swap! closed inc))] + (ptk/effect (#'mcp/init-mcp-plugin {:token "test-token"}) nil stream) + (.on @extension "connect" #(swap! calls inc)) + (rx/push! stream (ptk/data-event :app.main.data.workspace.mcp/connect)) + (t/is (= 1 @calls)) + (rx/push! stream (ptk/data-event stop-event)) + (t/is (= 1 @closed)) + (t/is (false? (.isConnectionRequested @extension))) + (.on @extension "connect" #(swap! calls inc)) + (rx/push! stream (ptk/data-event :app.main.data.workspace.mcp/connect)) + (t/is (= 1 @calls)) + (rx/end! stream))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index b25cf8ed43..f51ef64459 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -9218,6 +9218,18 @@ msgstr "Connect here" msgid "workspace.toolbar.mcp-connected" msgstr "MCP connected" +#: src/app/main/ui/workspace/top_toolbar.cljs +msgid "workspace.toolbar.mcp-copy-session-id" +msgstr "Copy session ID" + +#: src/app/main/ui/workspace/top_toolbar.cljs +msgid "workspace.toolbar.mcp-session-copied" +msgstr "Session ID copied" + +#: src/app/main/ui/workspace/top_toolbar.cljs +msgid "workspace.toolbar.mcp-session-id" +msgstr "Session ID: %s" + #: src/app/main/ui/workspace/top_toolbar.cljs:65, src/app/main/ui/workspace/top_toolbar.cljs:425 msgid "workspace.toolbar.move" msgstr "Move (%s)" diff --git a/mcp/README.md b/mcp/README.md index 0e24eef43c..1661f0d019 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -143,7 +143,7 @@ This bootstrap command will: 4. Load the plugin using the development URL (`http://localhost:4400/manifest.json` by default) 5. Open the plugin UI 6. In the plugin UI, click "Connect to MCP server". - The connection status should change from "Not connected" to "Connected to MCP server". + The connection status should change from "Not connected" to "Connected". (Check the browser's developer console for WebSocket connection logs. Check the MCP server terminal for WebSocket connection messages.) @@ -238,6 +238,24 @@ After updating the configuration file, restart Claude Desktop completely for the After the restart, you should see the MCP server listed when clicking on the "Search and tools" icon at the bottom of the prompt input area. +### Working with Multiple Files + +Connect the files you want to use: + +* Open each file in a separate Penpot tab, run the plugin, and connect it to the same MCP server. +* Each connection has its own short session ID, shown in the plugin UI with a **Copy** button. +* Multiple tabs can connect to the same file. Connecting or disconnecting one tab does not disconnect the others. + +Choose a session for your agent: + +* With exactly one connected session, the server selects it automatically when the agent omits the session ID. +* With several connected sessions, the server lists them and asks the agent to have you select one. +* You can also copy an ID from the plugin and include it in your prompt, for example: + "Use Penpot session `o37vgcqsvt` and list the pages in that file." + +For integrated remote MCP connection controls and session behavior, see the +[Help Center guide](../docs/mcp/index.md#working-with-multiple-files). + ## Repository Structure This repository is a monorepo containing four main components: diff --git a/mcp/packages/common/src/types.ts b/mcp/packages/common/src/types.ts index 36e52cc531..3809a14fdd 100644 --- a/mcp/packages/common/src/types.ts +++ b/mcp/packages/common/src/types.ts @@ -1,3 +1,16 @@ +/** Metadata identifying one connected Penpot plugin session. */ +export interface PenpotSession { + sessionId: string; + fileId: string; + fileName: string; +} + +/** First message sent by the plugin on each new WebSocket connection. */ +export interface PluginConnectionInit { + type: "initialize"; + session: PenpotSession; +} + /** * Result of a plugin task execution. * diff --git a/mcp/packages/plugin/index.html b/mcp/packages/plugin/index.html index de2ff5853c..bb525e1683 100644 --- a/mcp/packages/plugin/index.html +++ b/mcp/packages/plugin/index.html @@ -23,6 +23,22 @@ Disconnect MCP Server +
+
+