mirror of
https://github.com/penpot/penpot.git
synced 2026-11-04 17:10:39 +00:00
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.
139 lines
5.0 KiB
TypeScript
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,
|
|
});
|
|
});
|