mirror of
https://github.com/penpot/penpot.git
synced 2026-11-09 03:20:57 +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.
153 lines
5.4 KiB
TypeScript
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>;
|
|
}
|