alonso.torres c0f0cf22f8 ✨ Tell plugins and the MCP agent when changes are rolled back
The backend rejects a save after the call that made the changes has
returned, and the workspace then undoes them. A plugin, or the MCP
agent, went on working with shapes that were no longer there and never
learned why.

The rollback event now names the files that lost commits and the pages
and page shapes their changes touched, and plugins get it as the new
`contentrollback` event (`ContentRollback` type) through a subscription
to the store stream, so no rollback is dropped by debouncing.

The MCP plugin keeps the rollbacks it is told about and sends them with
its next task response, failed or not. The server collects them per tool
call and puts a warning first in that tool's response, naming what was
affected; the instructions tell the agent to read the pages again and
redo what is missing.
2026-11-03 11:17:40 +01:00

139 lines
5.0 KiB
TypeScript

import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler";
import { Task, TaskHandler } from "./TaskHandler";
import { formatTaskError } from "./ErrorUtils";
import { RollbackLog } from "./RollbackLog";
/**
* indicates whether the plugin is running in an environment with the Penpot-integrated remote MCP server
* enabled (as opposed to a local server used with the explicitly loaded plugin)
*/
const isIntegratedRemoteMcp = !!mcp;
/**
* Extracts the major.minor.patch prefix from a version string.
*
* @param version - a version string starting with major.minor.patch
* @returns the major.minor.patch prefix, or the original string if it does not match
*/
function extractVersionPrefix(version: string): string {
const match = version.match(/^(\d+\.\d+\.\d+)/);
return match ? match[1] : version;
}
mcp?.setMcpStatus("connecting");
/**
* Registry of all available task handlers.
*/
const taskHandlers: TaskHandler[] = [new ExecuteCodeTaskHandler()];
/**
* Rollbacks of rejected changes, reported to the MCP server with the next task response.
*/
const rollbackLog = new RollbackLog();
rollbackLog.listen();
// Open the plugin UI (main.ts)
penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}`, {
width: 236,
height: 210,
hidden: isIntegratedRemoteMcp,
} as any);
// Register message handlers
penpot.ui.onMessage<string | { id: string; type?: string; status?: string; task: string; params: any }>((message) => {
if (typeof message === "object" && message.type === "ui-initialized") {
// Inform the UI about the operating mode
penpot.ui.sendMessage({
type: "mcp-mode",
integratedRemoteMcp: isIntegratedRemoteMcp,
});
// Check Penpot version compatibility
const penpotVersionPrefix = penpot.version ? extractVersionPrefix(penpot.version) : "<2.15"; // pre-2.15 versions don't have version info
const mcpVersionPrefix = extractVersionPrefix(PENPOT_MCP_VERSION);
console.log(`Penpot version: ${penpotVersionPrefix}, MCP version: ${mcpVersionPrefix}`);
const isLocalPenpotVersion = penpotVersionPrefix == "0.0.0";
if (penpotVersionPrefix !== mcpVersionPrefix && !isLocalPenpotVersion) {
penpot.ui.sendMessage({
type: "version-mismatch",
mcpVersion: mcpVersionPrefix,
penpotVersion: penpotVersionPrefix,
});
}
// Initiate connection to remote MCP server (if enabled)
if (isIntegratedRemoteMcp) {
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) {
// Handle plugin tasks submitted by the MCP server
handlePluginTaskRequest(message).catch((error) => {
console.error("Error in handlePluginTaskRequest:", error);
});
}
});
/**
* Handles plugin task requests received from the MCP server via WebSocket.
*
* @param request - The task request containing ID, task type and parameters
*/
async function handlePluginTaskRequest(request: { id: string; task: string; params: any }): Promise<void> {
console.log("Executing plugin task:", request.task, request.params);
const task = new Task(request.id, request.task, request.params, rollbackLog);
// Find the appropriate handler
const handler = taskHandlers.find((h) => h.isApplicableTo(task));
if (handler) {
try {
// Cast the params to the expected type and handle the task
console.log("Processing task with handler:", handler);
await handler.handle(task);
// check whether a response was sent and send a generic success if not
if (!task.isResponseSent) {
console.warn("Handler did not send a response, sending generic success.");
task.sendSuccess("Task completed without a specific response.");
}
console.log("Task handled successfully:", task);
} catch (error) {
console.error("Error handling task:", error);
task.sendError(`Error handling task: ${formatTaskError(error)}`);
}
} else {
console.error("Unknown plugin task:", request.task);
task.sendError(`Unknown task type: ${request.task}`);
}
}
if (mcp) {
mcp.on("disconnect", async () => {
penpot.ui.sendMessage({
type: "stop-server",
});
});
mcp.on("connect", async () => {
penpot.ui.sendMessage({
type: "start-server",
url: mcp?.getServerUrl(),
token: mcp?.getToken(),
});
});
}
// Handle theme change in the iframe
penpot.on("themechange", (theme) => {
penpot.ui.sendMessage({
source: "penpot",
type: "themechange",
theme,
});
});