penpot/mcp/packages/server/src/RemotePluginTask.ts
alonso.torres 793760e489 ✨ 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 10:56:41 +01:00

59 lines
2.4 KiB
TypeScript

import { AbstractPluginTask, PluginTaskError } from "./PluginTask";
import { PluginTaskResult } from "@penpot/mcp-common";
import type { RedisBridge } from "./RedisBridge";
/**
* A plugin task whose outcome is forwarded back to a remote requester via Redis,
* rather than awaited in-process.
*
* This task type is used on the server instance that holds the plugin's WebSocket
* connection when a task request arrives over Redis (published by another instance
* that received the corresponding tool call). It is dispatched to the plugin through
* the ordinary local dispatch path; when the plugin responds, the response-correlation
* machinery settles this task, and the overridden `resolveWithResult`/`rejectWithError`
* publish the outcome back onto the requester's Redis response channel.
*
* Note that this task has its own ID (used to correlate the local WebSocket dispatch),
* distinct from the original requester's task ID, which keys the Redis response channel.
*
* It deliberately carries no result promise: settling the task *is* the side effect
* of publishing to Redis, and nothing awaits it locally.
*/
export class RemotePluginTask extends AbstractPluginTask<any, PluginTaskResult<any>> {
/**
* Creates a task that forwards its outcome to a Redis response channel.
*
* @param task - The name of the task to execute (from the incoming request)
* @param params - The parameters for task execution (from the incoming request)
* @param redisBridge - The Redis bridge used to publish the outcome
* @param originalTaskId - The ID of the original request, which keys the response
* channel the requesting instance is awaiting
*/
constructor(
task: string,
params: any,
private readonly redisBridge: RedisBridge,
private readonly originalTaskId: string
) {
super(task, params);
}
resolveWithResult(result: PluginTaskResult<any>): void {
this.redisBridge.publishTaskResponse(this.originalTaskId, {
id: this.originalTaskId,
success: true,
data: result.data,
rollbacks: result.rollbacks,
});
}
rejectWithError(error: Error): void {
this.redisBridge.publishTaskResponse(this.originalTaskId, {
id: this.originalTaskId,
success: false,
error: error.message,
rollbacks: error instanceof PluginTaskError ? error.rollbacks : undefined,
});
}
}