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

153 lines
5.4 KiB
TypeScript

import { z } from "zod";
import "reflect-metadata";
import { TextResponse, ToolResponse } from "./ToolResponse";
import type { PenpotMcpServer, SessionContext } from "./PenpotMcpServer";
import { createLogger } from "./logger";
import { RollbackNotices } from "./RollbackNotices";
/**
* An empty arguments class for tools that do not require any parameters.
*/
export class EmptyToolArgs {
static schema = {};
}
/**
* Base class for type-safe tools with automatic schema generation and validation.
*
* This class provides type safety through automatic validation and strongly-typed
* protected methods. All tools should extend this class.
*
* @template TArgs - The strongly-typed arguments class for this tool
*/
export abstract class Tool<TArgs extends object> {
public static readonly SESSION_ID_SCHEMA = z
.string()
.min(1)
.max(128)
.optional()
.describe(
"Penpot session to use. Optional when exactly one session is connected. " +
"If several sessions are listed in an error, ask the user to select one and pass its sessionId."
);
private readonly logger = createLogger("Tool");
/** monotonically increasing counter for unique tool execution IDs */
private static executionCounter = 0;
protected constructor(
protected mcpServer: PenpotMcpServer,
private inputSchema: z.ZodRawShape
) {}
/**
* Executes the tool with automatic validation and type safety.
*
* This method handles the unknown args from the MCP protocol,
* delegating to the type-safe implementation. A rollback reported by a
* plugin task during the execution puts a warning first in the response.
*/
async execute(args: unknown): Promise<ToolResponse> {
const [response, rollbacks] = await this.mcpServer.pluginBridge.rollbackNotices.collect(() =>
this.executeAndReport(args)
);
return RollbackNotices.withNotice(response, rollbacks);
}
/**
* Executes the tool logic, turning a failure into an error response.
*
* @param args - the tool arguments
*/
private async executeAndReport(args: unknown): Promise<ToolResponse> {
const executionId = ++Tool.executionCounter;
try {
let argsInstance: TArgs = args as TArgs;
this.logger.info("Tool execution #%d starting: %s", executionId, this.getToolName());
if (this.logger.isLevelEnabled("debug")) {
this.logger.debug("Tool execution #%d arguments: %s", executionId, this.formatArgs(argsInstance));
}
// execute the actual tool logic
let result = await this.executeCore(argsInstance);
this.logger.info("Tool execution #%d complete: %s", executionId, this.getToolName());
return result;
} catch (error) {
this.logger.error("Tool execution #%d failed: %s; error: %s", executionId, this.getToolName(), error);
return new TextResponse(`Tool execution failed: ${String(error)}`);
}
}
/**
* Formats tool arguments for readable logging.
*
* Multi-line strings are preserved with proper indentation.
*/
protected formatArgs(args: TArgs): string {
const formatted: string[] = [];
for (const [key, value] of Object.entries(args)) {
if (typeof value === "string" && value.includes("\n")) {
// multi-line string - preserve formatting with indentation
const indentedValue = value
.split("\n")
.map((line, index) => (index === 0 ? line : " " + line))
.join("\n");
formatted.push(` ${key}: ${indentedValue}`);
} else if (typeof value === "string") {
// single-line string
formatted.push(` ${key}: "${value}"`);
} else if (value === null || value === undefined) {
formatted.push(` ${key}: ${value}`);
} else {
// other types (numbers, booleans, objects, arrays)
const stringified = JSON.stringify(value, null, 2);
if (stringified.includes("\n")) {
// multi-line JSON - indent it
const indented = stringified
.split("\n")
.map((line, index) => (index === 0 ? line : " " + line))
.join("\n");
formatted.push(` ${key}: ${indented}`);
} else {
formatted.push(` ${key}: ${stringified}`);
}
}
}
return formatted.length > 0 ? "\n" + formatted.join("\n") : "{}";
}
/**
* Retrieves the current session context.
*
* @returns The session context for the current request, or undefined if not in a request context
*/
protected getSessionContext(): SessionContext | undefined {
return this.mcpServer.getSessionContext();
}
public getInputSchema() {
return this.inputSchema;
}
/**
* Returns the tool's unique name.
*/
public abstract getToolName(): string;
/**
* Returns the tool's description.
*/
public abstract getToolDescription(): string;
/**
* Executes the tool's core logic.
*
* @param args - The (typed) tool arguments
*/
protected abstract executeCore(args: TArgs): Promise<ToolResponse>;
}