penpot/mcp/packages/server/src/RollbackNotices.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

89 lines
4.0 KiB
TypeScript

import { AsyncLocalStorage } from "async_hooks";
import type { ContentRollbackEvent } from "@penpot/mcp-common";
import { TextContent, ToolResponse } from "./ToolResponse";
/**
* Collector of the rollbacks of rejected changes that plugin task responses report during one tool execution.
*
* The plugin reports a rollback with the first task response after it, so the rollback reaches the tool
* execution that follows the one which made the changes. {@link withNotice} then puts a warning at the
* top of that tool's response, so the LLM learns that some of its earlier changes are gone.
*/
export class RollbackNotices {
/**
* Maximum number of shape ids listed in a notice.
*/
private static readonly MAX_LISTED_SHAPES = 50;
private readonly storage = new AsyncLocalStorage<ContentRollbackEvent[]>();
/**
* Runs the given function, collecting the rollbacks recorded while it runs.
*
* @param fn - the function to run (typically a tool's core logic)
* @returns the function's value and the collected rollbacks
*/
async collect<T>(fn: () => Promise<T>): Promise<[T, ContentRollbackEvent[]]> {
const rollbacks: ContentRollbackEvent[] = [];
const value = await this.storage.run(rollbacks, fn);
return [value, rollbacks];
}
/**
* Records rollbacks reported by a plugin task response for the current tool execution.
* Rollbacks reported outside of {@link collect} are dropped.
*
* @param rollbacks - the rollbacks the response carried, if any
*/
record(rollbacks: ContentRollbackEvent[] | undefined): void {
if (rollbacks && rollbacks.length > 0) {
this.storage.getStore()?.push(...rollbacks);
}
}
/**
* Puts a rollback warning at the top of the given response.
*
* @param response - the tool response
* @param rollbacks - the rollbacks collected while the tool ran
* @returns the response, with the warning first if there were rollbacks
*/
static withNotice(response: ToolResponse, rollbacks: ContentRollbackEvent[]): ToolResponse {
if (rollbacks.length === 0) {
return response;
}
// clients may join text items without a separator
const notice = new TextContent(RollbackNotices.describe(rollbacks) + "\n\n");
response.content = [notice, ...response.content];
return response;
}
/**
* Describes the given rollbacks for the LLM.
*
* @param rollbacks - the rollbacks to describe (at least one)
*/
static describe(rollbacks: ContentRollbackEvent[]): string {
const fileIds = new Set(rollbacks.flatMap((r) => r.fileIds));
const pageIds = new Set(rollbacks.flatMap((r) => r.pageIds));
const shapeIds = [...new Set(rollbacks.flatMap((r) => r.shapeIds))];
const listedShapes = shapeIds.slice(0, RollbackNotices.MAX_LISTED_SHAPES);
const unlistedShapes = shapeIds.length - listedShapes.length;
return (
"WARNING: CHANGES WERE ROLLED BACK. Since your previous Penpot tool call, the Penpot backend rejected " +
`some changes ${rollbacks.length > 1 ? `(${rollbacks.length} times) ` : ""}and the workspace undid them, ` +
"together with the rest of their undo steps and any later change to the same file. " +
"Changes you made in earlier calls may be gone, even though those calls succeeded, and the tool call " +
"that returned this warning may have run on the reverted data.\n" +
"Before you continue, read the affected pages and shapes again, check that they hold what you expect, " +
"and redo what is missing. Do not rely on shape references or data you kept from before.\n" +
`Files: ${[...fileIds].join(", ") || "none"}\n` +
`Pages: ${[...pageIds].join(", ") || "none"}\n` +
`Shapes: ${listedShapes.join(", ") || "none"}` +
(unlistedShapes > 0 ? ` (and ${unlistedShapes} more)` : "")
);
}
}