diff --git a/mcp-server/src/ToolResponse.ts b/mcp-server/src/ToolResponse.ts index 33073d6..748d637 100644 --- a/mcp-server/src/ToolResponse.ts +++ b/mcp-server/src/ToolResponse.ts @@ -26,7 +26,7 @@ class ImageContent implements ImageItem { /** * @param data - PNG image data as Uint8Array or as object (from JSON conversion of Uint8Array) */ - protected static byteData(data: Uint8Array | object): Uint8Array { + public static byteData(data: Uint8Array | object): Uint8Array { if (typeof data === "object") { // convert object (as obtained from JSON conversion of Uint8Array) back to Uint8Array return new Uint8Array(Object.values(data) as number[]); @@ -36,7 +36,7 @@ class ImageContent implements ImageItem { } } -class PNGImageContent extends ImageContent { +export class PNGImageContent extends ImageContent { /** * @param data - PNG image data as Uint8Array or as object (from JSON conversion of Uint8Array) */ diff --git a/mcp-server/src/tools/ExecuteCodeTool.ts b/mcp-server/src/tools/ExecuteCodeTool.ts index 0150341..4080e80 100644 --- a/mcp-server/src/tools/ExecuteCodeTool.ts +++ b/mcp-server/src/tools/ExecuteCodeTool.ts @@ -12,7 +12,10 @@ import { ExecuteCodeTaskParams } from "@penpot-mcp/common"; */ export class ExecuteCodeArgs { static schema = { - code: z.string().min(1, "Code cannot be empty"), + code: z + .string() + .min(1, "Code cannot be empty") + .describe("The JavaScript code to execute in the plugin context."), }; /** diff --git a/mcp-server/src/tools/ExportShapeTool.ts b/mcp-server/src/tools/ExportShapeTool.ts index 54b72d0..edbae54 100644 --- a/mcp-server/src/tools/ExportShapeTool.ts +++ b/mcp-server/src/tools/ExportShapeTool.ts @@ -1,24 +1,38 @@ import { z } from "zod"; import { Tool } from "../Tool"; -import { PNGResponse, ToolResponse } from "../ToolResponse"; +import { PNGImageContent, PNGResponse, TextResponse, ToolResponse } from "../ToolResponse"; import "reflect-metadata"; import { PenpotMcpServer } from "../PenpotMcpServer"; import { ExecuteCodePluginTask } from "../tasks/ExecuteCodePluginTask"; -import { ExecuteCodeTaskParams } from "@penpot-mcp/common"; +import { FileUtils } from "../utils/FileUtils"; /** * Arguments class for ExecuteCodeTool */ export class ExportShapeArgs { static schema = { - shapeId: z.string().min(1, "shapeId cannot be empty"), + shapeId: z + .string() + .min(1, "shapeId cannot be empty") + .describe( + "Identifier of the shape to export. Use the special identifier 'selection' to " + + "export the first shape currently selected by the user." + ), + format: z.enum(["svg", "png"]).default("png").describe("The output format, either PNG (default) or SVG."), + filePath: z + .string() + .optional() + .describe( + "Optional file path to save the exported image to. If not provided, " + + "the image data is returned directly for you to see." + ), }; - /** - * Identifier of the shape to export. - * The special identifier "selection" can be used to refer to the (first) currently selected shape. - */ shapeId!: string; + + format: "svg" | "png" = "png"; + + filePath?: string; } /** @@ -35,29 +49,53 @@ export class ExportShapeTool extends Tool { } public getToolName(): string { - return "export_shapes"; + return "export_shape"; } public getToolDescription(): string { return ( - "Exports a shape from the Penpot design to a PNG image, such that you can get an impression of what the shape looks like.\n" + - "Parameter `shapeId`: identifier of the shapes to export. Use the special identifier 'selection' to " + - "export the first shape currently selected by the user." + "Exports a shape from the Penpot design to a PNG or SVG image, " + + "such that you can get an impression of what the shape looks like.\n" + + "Alternatively, you can save it to a file." ); } protected async executeCore(args: ExportShapeArgs): Promise { - // create code for exporting the shape - let taskParams: ExecuteCodeTaskParams; - if (args.shapeId === "selection") { - taskParams = { code: 'return penpot.selection[0].export({"type": "png"});' }; - } else { - throw new Error("Identifiers other than 'selection' are not yet supported"); + // check arguments + if (args.filePath) { + FileUtils.checkPathIsAbsolute(args.filePath); } - // execute the code and return the response - const task = new ExecuteCodePluginTask(taskParams); + // create code for exporting the shape + let shapeCode: string; + if (args.shapeId === "selection") { + shapeCode = `penpot.selection[0]`; + } else { + shapeCode = `penpotUtils.findShapeById("${args.shapeId}")`; + } + const code = `return ${shapeCode}.export({"type": "${args.format}"});`; + + // execute the code and obtain the image data + const task = new ExecuteCodePluginTask({ code: code }); const result = await this.mcpServer.pluginBridge.executePluginTask(task); - return new PNGResponse(result.data!.result); + const imageData = result.data!.result; + + // handle output and return response + if (!args.filePath) { + // return image data directly (for the LLM to "see" it) + if (args.format === "png") { + return new PNGResponse(imageData); + } else { + return new TextResponse(imageData); + } + } else { + // save image to file + if (args.format === "png") { + FileUtils.writeBinaryFile(args.filePath, PNGImageContent.byteData(imageData)); + } else { + FileUtils.writeTextFile(args.filePath, imageData); + } + return new TextResponse(`The shape has been exported to ${args.filePath}`); + } } } diff --git a/mcp-server/src/utils/FileUtils.ts b/mcp-server/src/utils/FileUtils.ts new file mode 100644 index 0000000..b20bd1a --- /dev/null +++ b/mcp-server/src/utils/FileUtils.ts @@ -0,0 +1,42 @@ +export class FileUtils { + /** + * Checks whether the given file path is absolute and raises an error if not. + * + * @param filePath - The file path to check + */ + public static checkPathIsAbsolute(filePath: string): void { + if (!require("path").isAbsolute(filePath)) { + throw new Error(`The specified file path must be absolute: ${filePath}`); + } + } + + public static createParentDirectories(filePath: string): void { + const path = require("path"); + const dir = path.dirname(filePath); + if (!require("fs").existsSync(dir)) { + require("fs").mkdirSync(dir, { recursive: true }); + } + } + + /** + * Writes binary data to a file at the specified path, creating the parent directories if necessary. + * + * @param filePath - The absolute path to the file where data should be written + * @param bytes - The binary data to write to the file + */ + public static writeBinaryFile(filePath: string, bytes: Uint8Array): void { + this.createParentDirectories(filePath); + require("fs").writeFileSync(filePath, Buffer.from(bytes)); + } + + /** + * Writes text data to a file at the specified path, creating the parent directories if necessary. + * + * @param filePath - The absolute path to the file where data should be written + * @param text - The text data to write to the file + */ + public static writeTextFile(filePath: string, text: string): void { + this.createParentDirectories(filePath); + require("fs").writeFileSync(filePath, text, { encoding: "utf-8" }); + } +}