🎉 Add MCP plugin embedded execution (#8368)

*  Add core changes for mcp server

*  Changes to plugins-runtime to add mcp extensions

*  Changes to MCP plugin

*  Changes post-review and ci fixes
This commit is contained in:
Alonso Torres 2026-02-17 09:18:46 +01:00 committed by GitHub
parent b87d7e3de0
commit c32a336c50
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 199 additions and 949 deletions

View File

@ -87,3 +87,20 @@
{:order-by [[:expires-at :asc] [:created-at :asc]]
:columns [:id :name :perms :type :created-at :updated-at :expires-at]})
(mapv decode-row)))
(def ^:private schema:get-current-mcp-token
[:map {:title "get-current-mcp-token"}])
(sv/defmethod ::get-current-mcp-token
{::doc/added "2.15"
::sm/params schema:get-current-mcp-token}
[{:keys [::db/pool]} {:keys [::rpc/profile-id ::rpc/request-at]}]
(->> (db/query pool :access-token
{:profile-id profile-id
:type "mcp"}
{:order-by [[:expires-at :asc] [:created-at :asc]]
:columns [:token :expires-at]})
(remove #(ct/is-after? (:expires-at %) request-at))
(map decode-row)
(first)))

View File

@ -107,4 +107,18 @@
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [results (:result out)]
(t/is (= 2 (count results))))))))
(t/is (= 2 (count results))))))
(t/testing "get mcp token"
(let [_ (th/command! {::th/type :create-access-token
::rpc/profile-id (:id prof)
:type "mcp"
:name "token 1"
:perms ["get-profile"]})
{:keys [error result]}
(th/command! {::th/type :get-current-mcp-token
::rpc/profile-id (:id prof)})]
;; (th/print-result! result)
(t/is (nil? error))
(t/is (string? (:token result)))))))

View File

@ -42,12 +42,13 @@
"clear:shadow-cache": "rm -rf .shadow-cljs",
"watch": "exit 0",
"watch:app": "pnpm run clear:shadow-cache && pnpm run build:wasm && concurrently --kill-others-on-fail \"pnpm run watch:app:assets\" \"pnpm run watch:app:main\" \"pnpm run watch:app:libs\"",
"watch:storybook": "pnpm run build:storybook:assets && concurrently --kill-others-on-fail \"storybook dev -p 6006 --no-open\" \"node ./scripts/watch-storybook.js\""
"watch:storybook": "pnpm run build:storybook:assets && concurrently --kill-others-on-fail \"storybook dev -p 6006 --no-open\" \"node ./scripts/watch-storybook.js\"",
"postinstall": "(cd ../plugins/libs/plugins-runtime; pnpm install; pnpm run build)"
},
"devDependencies": {
"@penpot/draft-js": "workspace:./packages/draft-js",
"@penpot/mousetrap": "workspace:./packages/mousetrap",
"@penpot/plugins-runtime": "1.4.2",
"@penpot/plugins-runtime": "link:../plugins/dist/plugins-runtime",
"@penpot/svgo": "penpot/svgo#v3.2",
"@penpot/text-editor": "workspace:./text-editor",
"@penpot/tokenscript": "workspace:./packages/tokenscript",

View File

@ -20,8 +20,8 @@ importers:
specifier: workspace:./packages/mousetrap
version: link:packages/mousetrap
'@penpot/plugins-runtime':
specifier: 1.4.2
version: 1.4.2
specifier: link:../plugins/dist/plugins-runtime
version: link:../plugins/dist/plugins-runtime
'@penpot/svgo':
specifier: penpot/svgo#v3.2
version: svgo@https://codeload.github.com/penpot/svgo/tar.gz/8c9b0e32e9cb5f106085260bd9375f3c91a5010b
@ -581,15 +581,6 @@ packages:
'@dabh/diagnostics@2.0.8':
resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==}
'@endo/cache-map@1.1.0':
resolution: {integrity: sha512-owFGshs/97PDw9oguZqU/px8Lv1d0KjAUtDUiPwKHNXRVUE/jyettEbRoTbNJR1OaI8biMn6bHr9kVJsOh6dXw==}
'@endo/env-options@1.1.11':
resolution: {integrity: sha512-p9OnAPsdqoX4YJsE98e3NBVhIr2iW9gNZxHhAI2/Ul5TdRfoOViItzHzTqrgUVopw6XxA1u1uS6CykLMDUxarA==}
'@endo/immutable-arraybuffer@1.1.2':
resolution: {integrity: sha512-u+NaYB2aqEugQ3u7w3c5QNkPogf8q/xGgsPaqdY6pUiGWtYiTiFspKFcha6+oeZhWXWQ23rf0KrUq0kfuzqYyQ==}
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'}
@ -1258,12 +1249,6 @@ packages:
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}
'@penpot/plugin-types@1.4.2':
resolution: {integrity: sha512-O8wU6RSYE8bIVU7g8cSTYi32ppxs3R13dq7X3Nn9tmDaJjBOKOBpVLuoRPIp3fJC65fv8/7om0sdrtFoL5v19g==}
'@penpot/plugins-runtime@1.4.2':
resolution: {integrity: sha512-y9TDZOnb96JBW9E33dHKpmTMeAPXLtHDIZruUVjtM8hBJWZK7RCv+vAGDGxeoZJC/OB2YAHrCZG+mukePBzcuQ==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@ -4636,9 +4621,6 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
ses@1.14.0:
resolution: {integrity: sha512-T07hNgOfVRTLZGwSS50RnhqrG3foWP+rM+Q5Du4KUQyMLFI3A8YA4RKl0jjZzhihC1ZvDGrWi/JMn4vqbgr/Jg==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@ -5499,9 +5481,6 @@ packages:
peerDependencies:
zod: ^3.25.0 || ^4.0.0
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
@ -5775,12 +5754,6 @@ snapshots:
enabled: 2.0.0
kuler: 2.0.0
'@endo/cache-map@1.1.0': {}
'@endo/env-options@1.1.11': {}
'@endo/immutable-arraybuffer@1.1.2': {}
'@esbuild/aix-ppc64@0.21.5':
optional: true
@ -6297,14 +6270,6 @@ snapshots:
'@parcel/watcher-win32-x64': 2.5.6
optional: true
'@penpot/plugin-types@1.4.2': {}
'@penpot/plugins-runtime@1.4.2':
dependencies:
'@penpot/plugin-types': 1.4.2
ses: 1.14.0
zod: 3.25.76
'@pkgjs/parseargs@0.11.0':
optional: true
@ -10000,12 +9965,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
ses@1.14.0:
dependencies:
'@endo/cache-map': 1.1.0
'@endo/env-options': 1.1.11
'@endo/immutable-arraybuffer': 1.1.2
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@ -10974,6 +10933,4 @@ snapshots:
dependencies:
zod: 4.3.6
zod@3.25.76: {}
zod@4.3.6: {}

View File

@ -119,6 +119,10 @@
(normalize-uri (or (obj/get global "penpotPublicURI")
(obj/get location "origin"))))
(def mcp-ws-uri
(or (some-> (obj/get global "penpotMcpServerURI") u/uri)
(u/join public-uri "mcp/ws")))
(def rasterizer-uri
(or (some-> (obj/get global "penpotRasterizerURI") normalize-uri)
public-uri))

View File

@ -65,8 +65,23 @@
(update [_ state]
(update-in state [:workspace-local :open-plugins] (fnil disj #{}) id))))
(defn start-plugin!
[{:keys [plugin-id name version description host code permissions allow-background]} ^js extensions]
(.ɵloadPlugin
^js ug/global
#js {:pluginId plugin-id
:name name
:version version
:description description
:host host
:code code
:allowBackground (boolean allow-background)
:permissions (apply array permissions)}
nil
extensions))
(defn- load-plugin!
[{:keys [plugin-id name description host code icon permissions]}]
[{:keys [plugin-id name description host code icon permissions] :as params}]
(try
(st/emit! (save-current-plugin plugin-id)
(reset-plugin-flags plugin-id))

View File

@ -52,6 +52,7 @@
[app.main.data.workspace.layers :as dwly]
[app.main.data.workspace.layout :as layout]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.mcp :as mcp]
[app.main.data.workspace.notifications :as dwn]
[app.main.data.workspace.pages :as dwpg]
[app.main.data.workspace.path :as dwdp]
@ -212,7 +213,8 @@
ptk/WatchEvent
(watch [_ _ _]
(rx/of (dp/check-open-plugin)
(fdf/fix-deleted-fonts-for-local-library file-id)))))
(fdf/fix-deleted-fonts-for-local-library file-id)
(mcp/init-mcp-connexion)))))
(defn- bundle-fetched
[{:keys [file file-id thumbnails] :as bundle}]

View File

@ -0,0 +1,58 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.data.workspace.mcp
(:require
[app.common.logging :as log]
[app.common.uri :as u]
[app.config :as cf]
[app.main.data.plugins :as dp]
[app.main.repo :as rp]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(log/set-level! :info)
(def ^:private default-manifest
{:code "plugin.js"
:name "Penpot MCP Plugin"
:version 2
:plugin-id "96dfa740-005d-8020-8007-55ede24a2bae"
:description "This plugin enables interaction with the Penpot MCP server"
:allow-background true
:permissions
#{"library:read" "library:write" "comment:read" "content:write" "comment:write"
"content:read"}})
(defn init-mcp!
[]
(->> (rp/cmd! :get-current-mcp-token)
(rx/subs!
(fn [{:keys [token]}]
(when token
(dp/start-plugin!
(assoc default-manifest
:url (str (u/join cf/public-uri "plugins/mcp/manifest.json"))
:host (str (u/join cf/public-uri "plugins/mcp/")))
;; API extension for MCP server
#js {:mcp
#js
{:getToken (constantly token)
:getServerUrl #(str cf/mcp-ws-uri)
:setMcpStatus
(fn [status]
;; TODO: Visual feedback
(log/info :hint "MCP STATUS" :status status))}}))))))
(defn init-mcp-connexion
[]
(ptk/reify ::init-mcp-connexion
ptk/EffectEvent
(effect [_ state _]
(when (and (contains? cf/flags :mcp)
(-> state :profile :props :mcp-status))
(init-mcp!)))))

11
mcp/packages/plugin/src/index.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
interface McpOptions {
getToken(): string;
getServerUrl(): string;
setMcpStatus(status: string);
}
declare global {
const mcp: undefined | McpOptions;
}
export {};

View File

@ -19,12 +19,19 @@ const statusElement = document.getElementById("connection-status");
* @param isConnectedState - whether the connection is in a connected state (affects color)
* @param message - optional additional message to append to the status
*/
function updateConnectionStatus(status: string, isConnectedState: boolean, message?: string): void {
function updateConnectionStatus(code: string, status: string, isConnectedState: boolean, message?: string): void {
if (statusElement) {
const displayText = message ? `${status}: ${message}` : status;
statusElement.textContent = displayText;
statusElement.style.color = isConnectedState ? "var(--accent-primary)" : "var(--error-700)";
}
parent.postMessage(
{
type: "update-connection-status",
status: code,
},
"*"
);
}
/**
@ -44,25 +51,24 @@ function sendTaskResponse(response: any): void {
/**
* Establishes a WebSocket connection to the MCP server.
*/
function connectToMcpServer(): void {
function connectToMcpServer(baseUrl?: string, token?: string): void {
if (ws?.readyState === WebSocket.OPEN) {
updateConnectionStatus("Already connected", true);
updateConnectionStatus("connected", "Already connected", true);
return;
}
try {
let wsUrl = PENPOT_MCP_WEBSOCKET_URL;
if (isMultiUserMode) {
// TODO obtain proper userToken from penpot
const userToken = "dummyToken";
wsUrl += `?userToken=${encodeURIComponent(userToken)}`;
let wsUrl = baseUrl || PENPOT_MCP_WEBSOCKET_URL;
if (isMultiUserMode && token) {
wsUrl += `?userToken=${encodeURIComponent(token)}`;
}
ws = new WebSocket(wsUrl);
updateConnectionStatus("Connecting...", false);
updateConnectionStatus("connecting", "Connecting...", false);
ws.onopen = () => {
console.log("Connected to MCP server");
updateConnectionStatus("Connected to MCP server", true);
updateConnectionStatus("connected", "Connected to MCP server", true);
};
ws.onmessage = (event) => {
@ -79,19 +85,19 @@ function connectToMcpServer(): void {
ws.onclose = (event: CloseEvent) => {
console.log("Disconnected from MCP server");
const message = event.reason || undefined;
updateConnectionStatus("Disconnected", false, message);
updateConnectionStatus("disconnected", "Disconnected", false, message);
ws = null;
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
// note: WebSocket error events typically don't contain detailed error messages
updateConnectionStatus("Connection error", false);
updateConnectionStatus("error", "Connection error", false);
};
} catch (error) {
console.error("Failed to connect to MCP server:", error);
const message = error instanceof Error ? error.message : undefined;
updateConnectionStatus("Connection failed", false, message);
updateConnectionStatus("error", "Connection failed", false, message);
}
}
@ -101,10 +107,14 @@ document.querySelector("[data-handler='connect-mcp']")?.addEventListener("click"
// Listen plugin.ts messages
window.addEventListener("message", (event) => {
if (event.data.source === "penpot") {
if (event.data.type === "start-server") {
connectToMcpServer(event.data.url, event.data.token);
} else if (event.data.source === "penpot") {
document.body.dataset.theme = event.data.theme;
} else if (event.data.type === "task-response") {
// Forward task response back to MCP server
sendTaskResponse(event.data.response);
}
});
parent.postMessage({ type: "ui-initialized" }, "*");

View File

@ -1,6 +1,8 @@
import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler";
import { Task, TaskHandler } from "./TaskHandler";
mcp?.setMcpStatus("connecting");
/**
* Registry of all available task handlers.
*/
@ -11,12 +13,24 @@ declare const IS_MULTI_USER_MODE: boolean;
const isMultiUserMode = typeof IS_MULTI_USER_MODE !== "undefined" ? IS_MULTI_USER_MODE : false;
// Open the plugin UI (main.ts)
penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}&multiUser=${isMultiUserMode}`, { width: 158, height: 200 });
penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}&multiUser=${isMultiUserMode}`, {
width: 158,
height: 200,
hidden: !!mcp,
} as any);
// Handle messages
penpot.ui.onMessage<string | { id: string; task: string; params: any }>((message) => {
penpot.ui.onMessage<string | { id: string; type?: string; status?: string; task: string; params: any }>((message) => {
// Handle plugin task requests
if (typeof message === "object" && message.task && message.id) {
if (mcp && typeof message === "object" && message.type === "ui-initialized") {
penpot.ui.sendMessage({
type: "start-server",
url: mcp?.getServerUrl(),
token: mcp?.getToken(),
});
} else if (typeof message === "object" && message.type === "update-connection-status") {
mcp?.setMcpStatus(message.status || "unknown");
} else if (typeof message === "object" && message.task && message.id) {
handlePluginTaskRequest(message).catch((error) => {
console.error("Error in handlePluginTaskRequest:", error);
});

View File

@ -4,7 +4,6 @@ import baseConfig from "./vite.config";
export default mergeConfig(
baseConfig,
defineConfig({
base: "./",
plugins: [],
})
);

View File

@ -23,7 +23,7 @@ export interface Penpot extends Omit<
open: (
name: string,
url: string,
options?: { width: number; height: number },
options?: { width: number; height: number; hidden: boolean },
) => void;
size: {

View File

@ -6,8 +6,8 @@
"ses": "^1.1.0",
"zod": "^3.22.4"
},
"module": "./index.mjs",
"typings": "./index.d.ts",
"module": "./dist/index.js",
"typings": "./dist/index.d.ts",
"type": "module",
"scripts": {
"build": "vite build",

View File

@ -24,6 +24,10 @@ export function createModal(
inlineStart: window.innerWidth - width - 290,
};
if (options?.hidden) {
modal.style.setProperty('display', 'none');
}
modal.style.setProperty(
'--modal-block-start',
`${initialPosition.blockStart}px`,

View File

@ -83,7 +83,7 @@ describe('createPlugin', () => {
expect.any(Function),
expect.any(Function),
);
expect(createSandbox).toHaveBeenCalledWith(mockPluginManager);
expect(createSandbox).toHaveBeenCalledWith(mockPluginManager, undefined);
expect(mockSandbox.evaluate).toHaveBeenCalled();
expect(result).toEqual({
plugin: mockPluginManager,

View File

@ -7,6 +7,7 @@ export async function createPlugin(
context: Context,
manifest: Manifest,
onCloseCallback: () => void,
apiExtensions?: object,
) {
const evaluateSandbox = async () => {
try {
@ -30,7 +31,7 @@ export async function createPlugin(
},
);
const sandbox = createSandbox(plugin);
const sandbox = createSandbox(plugin, apiExtensions);
evaluateSandbox();

View File

@ -2,8 +2,10 @@ import type { Penpot } from '@penpot/plugin-types';
import type { createPluginManager } from './plugin-manager';
import { createApi } from './api';
import { ses } from './ses.js';
export function createSandbox(
plugin: Awaited<ReturnType<typeof createPluginManager>>,
apiExtensions?: object,
) {
ses.hardenIntrinsics();
@ -51,7 +53,7 @@ export function createSandbox(
});
};
const publicPluginApi = {
let publicPluginApi = {
penpot: proxyApi,
fetch: ses.harden(safeFetch),
setTimeout: ses.harden(
@ -123,6 +125,10 @@ export function createSandbox(
structuredClone: ses.harden(window.structuredClone),
};
if (apiExtensions) {
publicPluginApi = Object.assign(publicPluginApi, apiExtensions);
}
const compartment = ses.createCompartment(publicPluginApi);
return {

View File

@ -78,6 +78,7 @@ describe('plugin-loader', () => {
mockContext,
manifest,
expect.any(Function),
undefined,
);
expect(mockPluginApi.plugin.close).not.toHaveBeenCalled();
expect(getPlugins()).toHaveLength(1);
@ -130,6 +131,7 @@ describe('plugin-loader', () => {
mockContext,
manifest,
expect.any(Function),
undefined,
);
});
@ -144,6 +146,7 @@ describe('plugin-loader', () => {
mockContext,
manifest,
expect.any(Function),
undefined,
);
});
});

View File

@ -19,7 +19,10 @@ export const getPlugins = () => plugins;
const closeAllPlugins = () => {
plugins.forEach((pluginApi) => {
pluginApi.plugin.close();
/* eslint-disable @typescript-eslint/no-explicit-any */
if (!(pluginApi.manifest as any)?.allowBackground) {
pluginApi.plugin.close();
}
});
plugins = [];
@ -38,6 +41,7 @@ window.addEventListener('message', (event) => {
export const loadPlugin = async function (
manifest: Manifest,
closeCallback?: () => void,
apiExtensions?: object,
) {
try {
const context = contextBuilder && contextBuilder(manifest.pluginId);
@ -58,6 +62,7 @@ export const loadPlugin = async function (
closeCallback();
}
},
apiExtensions,
);
plugins.push(plugin);
@ -70,8 +75,9 @@ export const loadPlugin = async function (
export const ɵloadPlugin = async function (
manifest: Manifest,
closeCallback?: () => void,
apiExtensions?: object,
) {
loadPlugin(manifest, closeCallback);
loadPlugin(manifest, closeCallback, apiExtensions);
};
export const ɵloadPluginByUrl = async function (manifestUrl: string) {

View File

@ -3,4 +3,5 @@ import { z } from 'zod';
export const openUISchema = z.object({
width: z.number().positive(),
height: z.number().positive(),
hidden: z.boolean().optional(),
});

873
plugins/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff