diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index 44eabf1863..53b40f6070 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -12,6 +12,7 @@ import { PenpotApiInfoTool } from "./tools/PenpotApiInfoTool"; import { ExportShapeTool } from "./tools/ExportShapeTool"; import { ImportImageTool } from "./tools/ImportImageTool"; import { CljsReplTool } from "./tools/CljsReplTool"; +import { ImportPenpotFileTool } from "./tools/ImportPenpotFileTool"; import { NreplClient } from "./NreplClient"; import { ReplServer } from "./ReplServer"; import { ApiDocs } from "./ApiDocs"; @@ -190,7 +191,9 @@ export class PenpotMcpServer { toolInstances.push(new ImportImageTool(this)); } if (this.isDevEnv()) { - toolInstances.push(new CljsReplTool(this, new NreplClient())); + const nreplClient = new NreplClient(); + toolInstances.push(new CljsReplTool(this, nreplClient)); + toolInstances.push(new ImportPenpotFileTool(this, nreplClient)); } return toolInstances.map((instance) => { diff --git a/mcp/packages/server/src/tools/ImportPenpotFileTool.ts b/mcp/packages/server/src/tools/ImportPenpotFileTool.ts new file mode 100644 index 0000000000..f55ae194f2 --- /dev/null +++ b/mcp/packages/server/src/tools/ImportPenpotFileTool.ts @@ -0,0 +1,370 @@ +import { z } from "zod"; +import { Tool } from "../Tool"; +import { TextResponse, ToolResponse } from "../ToolResponse"; +import "reflect-metadata"; +import { PenpotMcpServer } from "../PenpotMcpServer"; +import { NreplClient } from "../NreplClient"; +import { createLogger } from "../logger"; +import * as crypto from "crypto"; +import * as fs from "fs"; +import * as path from "path"; +import * as https from "https"; +import * as http from "http"; + +/** + * Arguments for ImportPenpotFileTool. + */ +export class ImportPenpotFileArgs { + static schema = { + url: z.url().describe("URL of the .penpot file to import."), + }; + + /** URL of the .penpot file to import */ + url!: string; +} + +/** + * Tool for importing a .penpot file into the running Penpot instance. + * + * Downloads the file from the given URL to a temporary location in the frontend's + * static directory, then triggers the import via the Penpot frontend's web worker + * using the ClojureScript REPL. The temporary file is cleaned up after the import + * completes (or fails). + * + * Only available in devenv mode, as it requires the ClojureScript nREPL connection. + */ +export class ImportPenpotFileTool extends Tool { + private static readonly POLL_INTERVAL_MS = 1_000; + private static readonly IMPORT_TIMEOUT_MS = 120_000; + + // assumes cwd is the server package root (same assumption as ConfigurationLoader) + private static readonly PUBLIC_DIR = path.resolve("../../../frontend/resources/public"); + + private static readonly NAVIGATION_HINT = + "To open an imported file in the workspace, use cljs_repl with:\n" + + "(do (require '[app.main.data.common :as dcm])\n" + + " (app.main.store/emit! (dcm/go-to-workspace\n" + + ' :team-id (parse-uuid "")\n' + + ' :file-id (parse-uuid "")\n' + + ' :page-id (parse-uuid ""))))'; + + private readonly log = createLogger("ImportPenpotFileTool"); + private readonly nreplClient: NreplClient; + + /** + * Creates a new ImportPenpotFileTool instance. + * + * @param mcpServer - the MCP server instance + * @param nreplClient - the nREPL client for communicating with shadow-cljs + */ + constructor(mcpServer: PenpotMcpServer, nreplClient: NreplClient) { + super(mcpServer, ImportPenpotFileArgs.schema); + this.nreplClient = nreplClient; + } + + public getToolName(): string { + return "import_penpot_file"; + } + + public getToolDescription(): string { + return ( + "Imports a .penpot file into the running Penpot instance from a given URL. " + + "The file is imported into the user's Drafts project. " + + "Returns the name(s) of the imported file(s)." + ); + } + + protected async executeCore(args: ImportPenpotFileArgs): Promise { + // generate a random filename for the temporary file + const randomName = `_import_${crypto.randomUUID()}.penpot`; + const tempFilePath = path.join(ImportPenpotFileTool.PUBLIC_DIR, randomName); + const servePath = `/${randomName}`; + + try { + // download the file + this.log.info("Downloading .penpot file from %s", args.url); + await this.downloadFile(args.url, tempFilePath); + const fileSize = fs.statSync(tempFilePath).size; + this.log.info("Downloaded %d bytes to %s", fileSize, tempFilePath); + + // set up the import via CLJS REPL + const atomName = `import-result-${crypto.randomUUID().slice(0, 8)}`; + const setupCode = this.buildImportCode(atomName, servePath); + + this.log.info("Initiating import via CLJS REPL"); + const setupResult = await this.nreplClient.evalCljs(setupCode); + this.log.debug("CLJS setup result: %s", JSON.stringify(setupResult)); + + // check for immediate errors in the setup + if (setupResult.err) { + throw new Error(`CLJS evaluation error: ${setupResult.err}`); + } + + // poll for the import result + const result = await this.pollForResult(atomName); + return new TextResponse(result); + } finally { + // clean up the temporary file + this.cleanupTempFile(tempFilePath); + } + } + + /** + * Builds the ClojureScript code that fetches the file from the static directory, + * creates a blob URL, and triggers the import via the web worker. + * + * @param atomName - unique name for the result atom + * @param servePath - the URL path to fetch the file from (same-origin) + * @returns the ClojureScript code string + */ + private buildImportCode(atomName: string, servePath: string): string { + // escape for embedding in a CLJS string + const escapedPath = servePath.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const escapedAtom = atomName.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + + return ` + (do + (require '[app.main.store :as st]) + (require '[app.main.worker :as mw]) + (require '[app.common.uuid :as uuid]) + (require '[beicon.v2.core :as rx]) + + (def ${escapedAtom} (atom {:status :pending})) + + (let [project-id (->> @st/state :projects vals (filter :is-default) first :id) + file-ids-before (set (keys (:files @st/state)))] + (-> (js/fetch "${escapedPath}") + (.then (fn [resp] + (when-not (.-ok resp) + (reset! ${escapedAtom} {:status :error :error (str "Fetch failed: " (.-status resp))}) + (throw (js/Error. (str "Fetch failed: " (.-status resp))))) + (.blob resp))) + (.then (fn [blob] + (let [uri (js/URL.createObjectURL blob) + file-id (uuid/next) + entries [{:file-id file-id + :name "import" + :type :binfile-v3 + :uri uri}]] + (->> (mw/ask-many! + {:cmd :import-files + :project-id project-id + :files entries + :features (get @st/state :features)}) + (rx/subs! + (fn [msg] + (when (= :finish (:status msg)) + (reset! ${escapedAtom} + {:status :success + :file-ids-before file-ids-before}))) + (fn [err] + (reset! ${escapedAtom} {:status :error :error (str err)})) + (fn [] + (when (= :pending (:status @${escapedAtom})) + (reset! ${escapedAtom} {:status :error :error "Stream completed without success message"})))))))) + (.catch (fn [err] + (when (= :pending (:status @${escapedAtom})) + (reset! ${escapedAtom} {:status :error :error (str err)})))))) + + :initiated) + `; + } + + /** + * Builds the ClojureScript code that resolves the imported file details. + * + * Refreshes the dashboard, diffs the file list against the pre-import snapshot, + * and for each new file fetches the first page-id via the backend API. + * + * @param atomName - the atom holding the import result (including :file-ids-before) + * @param resultAtomName - the atom to store the final file details in + * @returns the ClojureScript code string + */ + private buildResolveCode(atomName: string, resultAtomName: string): string { + return ` + (do + (require '[app.main.store :as st]) + (require '[app.main.repo :as rp]) + (require '[app.main.data.dashboard :as dd]) + (require '[beicon.v2.core :as rx]) + + (def ${resultAtomName} (atom {:status :pending})) + + (let [file-ids-before (:file-ids-before @${atomName}) + team-id (:current-team-id @st/state)] + ;; refresh dashboard files + (st/emit! (dd/fetch-recent-files)) + ;; wait a moment for the state to update, then resolve + (js/setTimeout + (fn [] + (let [all-files (vals (:files @st/state)) + new-files (remove #(contains? file-ids-before (:id %)) all-files) + file-count (count new-files)] + (if (zero? file-count) + (reset! ${resultAtomName} {:status :success :files []}) + ;; fetch page-ids for each new file + (let [remaining (atom file-count) + results (atom [])] + (doseq [f new-files] + (->> (rp/cmd! :get-file {:id (:id f) :features (get @st/state :features)}) + (rx/subs! + (fn [file-data] + (swap! results conj + {:file-id (str (:id f)) + :name (:name f) + :team-id (str team-id) + :page-id (str (first (get-in file-data [:data :pages])))}) + (when (zero? (swap! remaining dec)) + (reset! ${resultAtomName} {:status :success :files @results}))) + (fn [err] + (swap! results conj + {:file-id (str (:id f)) + :name (:name f) + :team-id (str team-id) + :error (str err)}) + (when (zero? (swap! remaining dec)) + (reset! ${resultAtomName} {:status :success :files @results})))))))))) + 500)) + + :initiated) + `; + } + + /** + * Polls the CLJS atom for the import result until it succeeds, fails, or times out. + * On success, resolves the imported file details (server-side IDs, names, page-ids). + * + * @param atomName - the name of the atom to poll + * @returns a JSON string with the imported file details + */ + private async pollForResult(atomName: string): Promise { + const startTime = Date.now(); + + // phase 1: wait for the import to complete + while (Date.now() - startTime < ImportPenpotFileTool.IMPORT_TIMEOUT_MS) { + await this.sleep(ImportPenpotFileTool.POLL_INTERVAL_MS); + + const pollResult = await this.nreplClient.evalCljs(`(pr-str @${atomName})`); + const resultStr = pollResult.values.join(""); + this.log.debug(`Poll result: ${resultStr}`); + + if (resultStr.includes(":success")) { + this.log.info("Import succeeded, resolving file details..."); + return await this.resolveImportedFiles(atomName); + } else if (resultStr.includes(":error")) { + this.log.error(`Import failed: ${resultStr}`); + throw new Error(`Import failed: ${resultStr}`); + } + } + + throw new Error(`Import timed out after ${ImportPenpotFileTool.IMPORT_TIMEOUT_MS / 1000} seconds`); + } + + /** + * After a successful import, resolves the actual server-side file details + * by diffing the dashboard file list and fetching page IDs. + * + * @param atomName - the atom holding the import result with :file-ids-before + * @returns a JSON string with the imported file details + */ + private async resolveImportedFiles(atomName: string): Promise { + const resultAtomName = `import-details-${crypto.randomUUID().slice(0, 8)}`; + const resolveCode = this.buildResolveCode(atomName, resultAtomName); + + await this.nreplClient.evalCljs(resolveCode); + + // poll the result atom + const startTime = Date.now(); + const resolveTimeoutMs = 15_000; + + while (Date.now() - startTime < resolveTimeoutMs) { + await this.sleep(ImportPenpotFileTool.POLL_INTERVAL_MS); + + const pollResult = await this.nreplClient.evalCljs(`(pr-str @${resultAtomName})`); + const resultStr = pollResult.values.join(""); + + if (resultStr.includes(":success")) { + this.log.info("File details resolved"); + return resultStr + "\n\n" + ImportPenpotFileTool.NAVIGATION_HINT; + } + } + + this.log.warn("Timed out resolving file details, returning basic success"); + return "Import succeeded but could not resolve file details."; + } + + /** + * Downloads a file from a URL to a local path. + * + * @param url - the URL to download from + * @param destPath - the local file path to write to + */ + private downloadFile(url: string, destPath: string): Promise { + return new Promise((resolve, reject) => { + const client = url.startsWith("https") ? https : http; + const file = fs.createWriteStream(destPath); + + const request = client.get(url, (response) => { + // handle redirects + if ( + response.statusCode && + response.statusCode >= 300 && + response.statusCode < 400 && + response.headers.location + ) { + file.close(); + fs.unlinkSync(destPath); + this.downloadFile(response.headers.location, destPath).then(resolve, reject); + return; + } + + if (response.statusCode && response.statusCode !== 200) { + file.close(); + fs.unlinkSync(destPath); + reject(new Error(`Download failed with status ${response.statusCode}`)); + return; + } + + response.pipe(file); + file.on("finish", () => { + file.close(); + resolve(); + }); + }); + + request.on("error", (err) => { + file.close(); + if (fs.existsSync(destPath)) { + fs.unlinkSync(destPath); + } + reject(new Error(`Download error: ${err.message}`)); + }); + + file.on("error", (err) => { + file.close(); + if (fs.existsSync(destPath)) { + fs.unlinkSync(destPath); + } + reject(new Error(`File write error: ${err.message}`)); + }); + }); + } + + /** + * Removes the temporary file, logging but not throwing on failure. + */ + private cleanupTempFile(filePath: string): void { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + this.log.info("Cleaned up temporary file: %s", filePath); + } + } catch (err) { + this.log.warn("Failed to clean up temporary file %s: %s", filePath, err); + } + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +}