🐛 Keep the MCP plugin working without penpotMgmt

Penpot only exposes penpotMgmt to plugins running with global
scope. Without it, the MCP plugin failed on load when registering
its workspacechange listener.

When penpotMgmt is missing, the plugin now builds the workspace
metadata from the open file and skips the listener. execute_code
receives penpotMgmt as null and waits for layout updates as before.
The tool description and instructions tell agents that only the
open file is available when penpotMgmt is null.
This commit is contained in:
alonso.torres 2026-10-07 13:24:17 +02:00
parent dfff188409
commit a125977fce
6 changed files with 72 additions and 6 deletions

View File

@ -192,6 +192,44 @@ test("plugin reads the current file for each connection metadata request", () =>
]);
});
test("plugin without penpotMgmt derives metadata from the open file", () => {
let onMessage!: (message: any) => void;
const messages: any[] = [];
const penpot = {
currentFile: { id: "file-1", name: "Design" } as { id: string; name: string } | null,
currentUser: { sessionId: "tab-1" },
theme: "dark",
ui: {
open() {},
onMessage: (handler: (message: any) => void) => {
onMessage = handler;
},
sendMessage: (message: any) => messages.push(JSON.parse(JSON.stringify(message))),
},
on() {},
};
runInNewContext(compile("plugin.ts"), {
exports: {},
penpot,
mcp: undefined,
require: () => ({ ExecuteCodeTaskHandler: class {} }),
});
onMessage({ type: "connection-metadata-request", sessionId: "first" });
penpot.currentFile = null;
onMessage({ type: "context-request" });
assert.deepEqual(messages, [
{
type: "initialize",
penpotUserSessionId: "tab-1",
session: { sessionId: "first", fileId: "file-1", fileName: "Design", workspaceState: "ready" },
},
{
type: "context-update",
context: { fileId: null, fileName: null, workspaceState: "none" },
},
]);
});
test("integrated connections reuse the short session ID across reconnects", async () => {
const ui = pluginUi();
ui.message({ type: "mcp-mode", integratedRemoteMcp: true });

View File

@ -8,7 +8,8 @@ interface McpOptions {
declare global {
const mcp: undefined | McpOptions;
const penpotMgmt: import("../../../../plugins/libs/plugin-types").PenpotMgmt;
/** management API; only exposed to plugins running with global scope */
const penpotMgmt: undefined | null | import("../../../../plugins/libs/plugin-types").PenpotMgmt;
}
export {};

View File

@ -9,6 +9,12 @@ import type { PluginConnectionInit } from "../../common/src";
*/
const isIntegratedRemoteMcp = !!mcp;
/**
* the management API, which Penpot only exposes to plugins running with global scope;
* null otherwise (e.g. in Penpot versions without global plugins)
*/
const management = typeof penpotMgmt === "undefined" ? null : penpotMgmt;
/**
* Extracts the major.minor.patch prefix from a version string.
*
@ -66,7 +72,7 @@ penpot.ui.onMessage<
});
}
} else if (typeof message === "object" && message.type === "connection-metadata-request") {
const workspace = penpotMgmt.workspace;
const workspace = currentWorkspace();
if (message.sessionId) {
const initialization: PluginConnectionInit & { penpotUserSessionId?: string } = {
type: "initialize",
@ -92,9 +98,26 @@ penpot.ui.onMessage<
}
});
/**
* Provides the workspace metadata of this tab.
* Without the management API, the metadata is derived from the open file.
*/
function currentWorkspace(): NonNullable<typeof penpotMgmt>["workspace"] {
if (management) {
return management.workspace;
}
const file = penpot.currentFile;
return {
status: file ? "ready" : "none",
fileId: file?.id ?? null,
fileName: file?.name ?? null,
teamId: null,
};
}
/** Sends the current workspace metadata without replacing the connection. */
function sendWorkspaceContext(): void {
const workspace = penpotMgmt.workspace;
const workspace = currentWorkspace();
penpot.ui.sendMessage({
type: "context-update",
context: {
@ -105,7 +128,7 @@ function sendWorkspaceContext(): void {
});
}
penpotMgmt.on("workspacechange", sendWorkspaceContext);
management?.on("workspacechange", sendWorkspaceContext);
/**
* Handles plugin task requests received from the MCP server via WebSocket.

View File

@ -178,7 +178,7 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
// initialize context, making penpot, penpotUtils, storage and the custom console available
this.context = {
penpot: penpot,
penpotMgmt: penpotMgmt,
penpotMgmt: typeof penpotMgmt === "undefined" ? null : penpotMgmt,
storage: {},
console: new ExecuteCodeTaskConsole(),
penpotUtils: PenpotUtils,
@ -217,8 +217,9 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
try {
// wait for layout updates prior to executing the supplied code (if method is available)
try {
const management = this.context.penpotMgmt;
// @ts-ignore - TODO Penpot.waitForLayoutUpdate is not yet in the released types
if (penpotMgmt.workspace.status === "ready" && penpot.waitForLayoutUpdate) {
if ((!management || management.workspace.status === "ready") && penpot.waitForLayoutUpdate) {
// @ts-ignore
await penpot.waitForLayoutUpdate();
}

View File

@ -441,6 +441,8 @@ Create a file with `await penpotMgmt.createFile({ projectId: project.id, name: "
Both creation methods require `content:write` and return metadata without changing the current view.
To edit the new file, call `await penpotMgmt.openFile(file.id, { teamId: file.teamId })` and reacquire file, page and shape objects.
You navigate to different design files only when your task explicitly requires it/you are instructed to do so.
If `penpotMgmt` is `null`, file management is unavailable and only the file that is open in Penpot can be used;
ask the user to open another file if needed.
--
You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again.

View File

@ -50,6 +50,7 @@ export class ExecuteCodeTool extends Tool<ExecuteCodeArgs> {
"IMPORTANT: Before using this tool, make sure you have read the 'Penpot High-Level Overview' and know " +
"which Penpot API functionality is necessary and how to use it.\n" +
"You have access to `penpot` (of type `Penpot`), `penpotMgmt` (of type `PenpotMgmt`), `penpotUtils`, and `storage`.\n" +
"If `penpotMgmt` is null, file management is unavailable and only the open file can be used.\n" +
"`storage` is an object in which arbitrary data can be stored, simply by adding a new attribute; " +
"stored attributes can be referenced in future calls to this tool, so any intermediate results that " +
"could come in handy later should be stored in `storage` instead of just a fleeting variable; " +