penpot/mcp/packages/plugin/src/TaskHandler.ts
alonso.torres 7538d2c187 ✨ 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-05 23:59:32 +01:00

109 lines
3.8 KiB
TypeScript

import type { RollbackLog } from "./RollbackLog";
/**
* Represents a task received from the MCP server in the Penpot MCP plugin
*/
export class Task<TParams = any> {
public isResponseSent: boolean = false;
/**
* @param requestId Unique identifier for the task request
* @param taskType The type of the task to execute
* @param params Task parameters/arguments
* @param rollbackLog Log whose pending rollbacks the response carries, if any
*/
constructor(
public requestId: string,
public taskType: string,
public params: TParams,
private readonly rollbackLog?: RollbackLog
) {}
/**
* Sends a task response back to the MCP server.
*/
protected sendResponse(success: boolean, data: any = undefined, error: any = undefined): void {
if (this.isResponseSent) {
console.error("Response already sent for task:", this.requestId);
return;
}
// rollbacks the server has not been told about, whatever the task did
const rollbacks = this.rollbackLog?.pending() ?? [];
// create response object
const response = {
type: "task-response",
// NOTE: This inner response schema also constructed in main.ts/sendTaskResponse.
response: {
id: this.requestId,
success: success,
data: data,
error: error,
...(rollbacks.length > 0 ? { rollbacks } : {}),
},
};
// Send to main.ts which will forward to MCP server via WebSocket
try {
penpot.ui.sendMessage(response, true);
} catch (sendError) {
console.error("Failed to transfer task response for transmission to MCP server", sendError);
if (success) {
console.info("Original response was a success response, sending an error response instead.");
// If we were trying to send a success response, send an error response instead
let cause = sendError instanceof Error ? sendError.message : String(sendError);
this.sendError(
"Unexpected error sending task response: " +
cause +
" If you are an LLM and tried to serialize a regular object (shape, token, etc.), " +
"tell the user to report this error, providing information on the request you made."
);
} else {
console.info(
"Original response was an error response, abandoning attempt to send alternative response to avoid infinite error loop."
);
}
return;
}
console.log("Sent task response:", response);
this.isResponseSent = true;
this.rollbackLog?.acknowledge(rollbacks.length);
}
public sendSuccess(data: any = undefined): void {
this.sendResponse(true, data);
}
public sendError(error: string): void {
this.sendResponse(false, undefined, error);
}
}
/**
* Abstract base class for task handlers in the Penpot MCP plugin.
*
* @template TParams - The type of parameters this handler expects
*/
export abstract class TaskHandler<TParams = any> {
/** The task type this handler is responsible for */
abstract readonly taskType: string;
/**
* Checks if this handler can process the given task.
*
* @param task - The task identifier to check
* @returns True if this handler applies to the given task
*/
isApplicableTo(task: Task): boolean {
return this.taskType === task.taskType;
}
/**
* Handles the task with the provided parameters.
*
* @param task - The task to be handled
*/
abstract handle(task: Task<TParams>): Promise<void>;
}