alonso.torres d09bc9c3ee 🐛 Keep reporting rollbacks the MCP agent has not heard about
The MCP plugin sends the rollbacks it saw with its next task response
and then forgets them. When that response came after the server had
given up on the task, the server dropped it and the agent never heard
about the rollback. A reload of the tab or of the plugin lost them too.

The server now sends the rollbacks of such a late response back to the
plugin, which reports them again with its next response. The plugin
keeps the rollbacks not reported yet in its local storage, one entry
per file, so a reload does not lose them. Both MCP plugin manifests ask
for the allow:localstorage permission for that.
2026-11-05 10:56:46 +01:00

218 lines
7.8 KiB
TypeScript

import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler";
import { Task, TaskHandler } from "./TaskHandler";
import { formatTaskError } from "./ErrorUtils";
import { extractMinorVersion, isCompatibleVersion } from "./VersionCompatibility";
import type { ContentRollbackEvent, PluginConnectionInit } from "../../common/src";
import type { WorkspaceContext } from "@penpot/plugin-types";
import { LocalStorageRollbackStore, RollbackLog, type RollbackStore } 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;
/**
* 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;
let uiInitialized = false;
/**
* 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.
* The ones not reported yet are kept in the plugin local storage of the open file, so a reload
* of the tab or of the plugin does not lose them.
*/
const rollbackLog = new RollbackLog(createRollbackStore());
rollbackLog.listen();
/**
* Creates the store of the rollbacks not reported yet, for the open file.
*
* @returns the store, or undefined when no file is open or the local storage is unavailable
*/
function createRollbackStore(): RollbackStore | undefined {
try {
const fileId = penpot.currentFile?.id;
return fileId ? new LocalStorageRollbackStore(penpot.localStorage, fileId) : undefined;
} catch (error) {
console.warn("Rollbacks are kept in memory only:", error);
return undefined;
}
}
// 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;
sessionId?: string;
task: string;
params: any;
rollbacks?: ContentRollbackEvent[];
}
>((message) => {
if (typeof message === "object" && message.type === "ui-initialized") {
uiInitialized = true;
// Inform the UI about the operating mode
penpot.ui.sendMessage({
type: "mcp-mode",
integratedRemoteMcp: isIntegratedRemoteMcp,
});
// Check Penpot version compatibility
const penpotVersionPrefix = penpot.version ? extractMinorVersion(penpot.version) : "<2.15"; // pre-2.15 versions don't have version info
const mcpVersionPrefix = extractMinorVersion(PENPOT_MCP_VERSION);
console.log(`Penpot version: ${penpotVersionPrefix}, MCP version: ${mcpVersionPrefix}`);
if (!isCompatibleVersion(penpotVersionPrefix, mcpVersionPrefix)) {
penpot.ui.sendMessage({
type: "version-mismatch",
mcpVersion: mcpVersionPrefix,
penpotVersion: penpotVersionPrefix,
});
}
// connect only when requested in this tab
if (isIntegratedRemoteMcp && mcp?.isConnectionRequested()) {
penpot.ui.sendMessage({
type: "start-server",
url: mcp?.getServerUrl(),
token: mcp?.getToken(),
});
}
} else if (typeof message === "object" && message.type === "connection-metadata-request") {
const workspace = currentWorkspace();
if (message.sessionId) {
const initialization: PluginConnectionInit & { penpotUserSessionId?: string } = {
type: "initialize",
session: {
sessionId: message.sessionId,
fileId: workspace.fileId,
fileName: workspace.fileName,
workspaceState: workspace.status,
},
penpotUserSessionId: penpot.currentUser.sessionId,
};
penpot.ui.sendMessage(initialization);
}
} else if (typeof message === "object" && message.type === "context-request") {
sendWorkspaceContext();
} else if (typeof message === "object" && message.type === "unclaimed-rollbacks") {
// the server got these with a response that came too late: report them again
rollbackLog.restore(message.rollbacks ?? []);
} else if (typeof message === "object" && message.type === "update-connection-status") {
mcp?.setMcpStatus(message.status || "unknown", message.sessionId);
} 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);
});
}
});
/**
* Provides the workspace metadata of this tab.
* Without the management API, the metadata is derived from the open file.
*/
function currentWorkspace(): WorkspaceContext {
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 = currentWorkspace();
penpot.ui.sendMessage({
type: "context-update",
context: {
fileId: workspace.fileId,
fileName: workspace.fileName,
workspaceState: workspace.status,
},
});
}
management?.on("workspacechange", sendWorkspaceContext);
/**
* 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 () => {
if (!uiInitialized) return;
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,
});
});