diff --git a/.serena/memories/devenv/cljs-repl-access.md b/.serena/memories/devenv/cljs-repl.md similarity index 50% rename from .serena/memories/devenv/cljs-repl-access.md rename to .serena/memories/devenv/cljs-repl.md index 86f2f1794f..f50d337ef4 100644 --- a/.serena/memories/devenv/cljs-repl-access.md +++ b/.serena/memories/devenv/cljs-repl.md @@ -3,15 +3,6 @@ ## Overview The penpot frontend uses shadow-cljs with `:target :esm` and multi-module code splitting. The CLJS REPL evaluates code in the browser runtime via a websocket connection. -## Known Pitfall: Rasterizer vs Workspace Runtime -The workspace page embeds a rasterizer iframe (`rasterizer.html`) that also loads the `:main` shadow-cljs build. Both runtimes register with shadow-cljs. If the rasterizer connects first, the REPL will target it instead of the workspace — and the rasterizer has an **empty app state** (its own `defonce` store instance). - -**Symptoms:** `@st/state` returns nil, `(.-title js/document)` returns "Penpot - Rasterizer". - -**Fix:** Restart the devenv (`docker restart penpot-devenv-main`) and reload the browser. After a clean restart, the workspace runtime typically connects first. - -**Verification:** Run `(.-title js/document)` — it should show your file name (e.g. "New File 1 - Penpot"), not "Penpot - Rasterizer". - ## Method 1: Interactive REPL (inside container) ```bash docker exec -it penpot-devenv-main bash @@ -49,27 +40,52 @@ To list connected runtimes and their client-ids: A custom Python nREPL client exists at `tools/nrepl_eval.py`. However, it uses `(shadow/repl :main)` to switch to CLJS mode, which doesn't reliably select the correct runtime. **Prefer Method 2 for automation.** ## Accessing App State + +The main store is `app.main.store/state`. It contains workspace metadata, selection, UI state, etc. +However, **page objects are NOT in the main store atom**. They live behind derived refs. + +### Top-level store keys (subset) +`:current-page-id`, `:current-file-id`, `:workspace-local`, `:workspace-global`, +`:workspace-trimmed-page`, `:workspace-undo`, `:workspace-guides`, `:workspace-layout`, +`:workspace-drawing`, `:workspace-presence`, `:workspace-ready`, `:profile`, `:route`, etc. + +**Notable absence:** There is no `:workspace-data` key in the store. The old path +`(get-in state [:workspace-data :pages-index page-id :objects])` does NOT work. + +### Getting page objects — use `app.main.refs/workspace-page-objects` ```clojure -(require '[app.main.store :as st]) -(some? @st/state) ;; should be true - -;; Get current page id -(:current-page-id @st/state) - -;; Get objects on current page -(let [state @st/state - page-id (:current-page-id state) - objects (get-in state [:workspace-data :pages-index page-id :objects])] - (count objects)) - -;; Get a specific shape -(let [state @st/state - page-id (:current-page-id state) - objects (get-in state [:workspace-data :pages-index page-id :objects]) +;; This is a derived ref (reactive lens). Deref it directly: +(let [objects @app.main.refs/workspace-page-objects shape (get objects (parse-uuid "some-uuid-here"))] - (select-keys shape [:name :type :component-id :component-file :component-root])) + (select-keys shape [:name :type :x :y :width :height :fills :strokes :rotation :opacity :frame-id :parent-id])) ``` +### Getting the current selection +```clojure +;; Selection is in the main store under :workspace-local :selected +(let [state @app.main.store/state + selected (get-in state [:workspace-local :selected])] + (mapv str selected)) +;; Returns vector of UUID strings for selected shapes +``` + +### Other useful store access +```clojure +;; Current page id +(:current-page-id @app.main.store/state) + +;; Verify state is accessible +(some? @app.main.store/state) ;; should be true + +;; workspace-local keys: :zoom :selected :hide-toolbar :last-selected :vbox +;; :highlighted :vport :expanded :selrect :zoom-inverse +``` + +### Shape data structure (internal ClojureScript representation) +Shape keys use kebab-case keywords (`:fill-color`, `:fill-opacity`, `:parent-id`, `:frame-id`). +The shape `:type` is a keyword like `:rect`, `:path`, `:text`, `:ellipse`, `:image`, `:bool`, `:svg-raw`, `:frame`, `:group`. +Note `:rect` in CLJS corresponds to "rectangle" in the JS Plugin API, and `:frame` corresponds to "board". + ## Notes - nREPL server runs on port 3447 inside the container, mapped to host - The `:main` build has multiple modules: shared, main, main-workspace, rasterizer, etc. @@ -77,3 +93,8 @@ A custom Python nREPL client exists at `tools/nrepl_eval.py`. However, it uses ` - Ignore the "WARNING: shadow-cljs not installed in project" message — it works via the running server - Use `timeout` to avoid hanging if the browser is disconnected - `DO NOT` call `shadow.cljs.devtools.api/repl-runtime-select` with a runtime that can't eval — it will jam the REPL until restart + +## Troubleshooting + +The REPL may occasionally not connect to the right runtime. +Run `(.-title js/document)` to verify — it should show your file name (e.g. "New File 1 - Penpot"), not "Penpot - Rasterizer". diff --git a/docker/devenv/files/start-tmux.sh b/docker/devenv/files/start-tmux.sh index 0c351fd6f6..4deeca6801 100755 --- a/docker/devenv/files/start-tmux.sh +++ b/docker/devenv/files/start-tmux.sh @@ -50,7 +50,7 @@ if echo "$PENPOT_FLAGS" | grep -q "enable-mcp"; then tmux new-window -t penpot:4 -n 'mcp server' tmux select-window -t penpot:4 tmux send-keys -t penpot 'cd penpot/mcp' enter C-l - tmux send-keys -t penpot 'PENPOT_MCP_SERVER_HOST=0.0.0.0 PENPOT_MCP_REMOTE_MODE=true pnpm run start' enter + tmux send-keys -t penpot './scripts/start-mcp-devenv' enter fi tmux -2 attach-session -t penpot diff --git a/mcp/README.md b/mcp/README.md index 24e283077f..d0e2fb49aa 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -272,6 +272,7 @@ The Penpot MCP server can be configured using environment variables. | `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` | | `PENPOT_MCP_SERVER_ADDRESS` | Hostname or IP address via which clients can reach the MCP server | `localhost` | | `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` | +| `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools. Set to `true` to enable. | `false` | ### Logging Configuration diff --git a/mcp/packages/server/package.json b/mcp/packages/server/package.json index 68d33859ac..0985317bfe 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -5,7 +5,7 @@ "type": "module", "main": "dist/index.js", "scripts": { - "build:server": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:pino-loki --external:js-yaml --external:sharp", + "build:server": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:pino-loki --external:js-yaml --external:sharp --external:nrepl-client", "build": "pnpm run build:server && node scripts/copy-resources.js", "build:types": "tsc --emitDeclarationOnly --outDir dist", "start": "node dist/index.js", @@ -29,6 +29,7 @@ "class-validator": "^0.14.3", "express": "^5.1.0", "js-yaml": "^4.1.1", + "nrepl-client": "^0.3.0", "penpot-mcp": "file:..", "pino": "^9.10.0", "pino-loki": "^2.6.0", diff --git a/mcp/packages/server/src/NreplClient.ts b/mcp/packages/server/src/NreplClient.ts new file mode 100644 index 0000000000..372bc9dcd0 --- /dev/null +++ b/mcp/packages/server/src/NreplClient.ts @@ -0,0 +1,142 @@ +import nreplClient from "nrepl-client"; +import { createLogger } from "./logger"; + +/** + * Result of evaluating a ClojureScript expression via nREPL. + */ +export interface NreplEvalResult { + /** the returned value(s) as strings */ + values: string[]; + /** captured stdout output */ + out: string; + /** captured stderr output */ + err: string; + /** the namespace after evaluation */ + ns: string; +} + +/** + * A client for communicating with a shadow-cljs nREPL server. + * + * This client wraps the nrepl-client library, providing a typed, promise-based + * interface for evaluating Clojure and ClojureScript expressions. + */ +export class NreplClient { + private static readonly NREPL_PORT = 3447; + private static readonly NREPL_HOST = "localhost"; + private static readonly EVAL_TIMEOUT_MS = 30_000; + + private readonly logger = createLogger("NreplClient"); + + /** + * Evaluates a Clojure expression on the nREPL server. + * + * A new connection is established for each evaluation and closed afterwards. + * + * @param code - the Clojure expression to evaluate + * @returns the evaluation result + */ + async eval(code: string): Promise { + this.logger.debug("Evaluating Clojure expression: %s", code); + return this.withConnection((conn) => { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`nREPL evaluation timed out after ${NreplClient.EVAL_TIMEOUT_MS}ms`)); + }, NreplClient.EVAL_TIMEOUT_MS); + + conn.eval(code, (err: Error | null, result: any[]) => { + clearTimeout(timeout); + if (err) { + reject(err); + return; + } + resolve(this.parseEvalResult(result)); + }); + }); + }); + } + + /** + * Evaluates a ClojureScript expression via the shadow-cljs CLJS eval API. + * + * The expression is wrapped in a call to `shadow.cljs.devtools.api/cljs-eval` + * targeting the `:main` build, so it is evaluated in the browser runtime. + * + * @param cljsCode - the ClojureScript expression to evaluate + * @returns the evaluation result + */ + async evalCljs(cljsCode: string): Promise { + // escape the CLJS code for embedding in a Clojure string + const escapedCode = cljsCode.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const wrappedCode = `(shadow.cljs.devtools.api/cljs-eval :main "${escapedCode}" {})`; + this.logger.debug("Evaluating CLJS expression via shadow-cljs: %s", cljsCode); + return this.eval(wrappedCode); + } + + /** + * Opens a connection, executes the given operation, and ensures the connection is closed afterwards. + */ + private async withConnection(operation: (conn: any) => Promise): Promise { + const conn = nreplClient.connect({ + port: NreplClient.NREPL_PORT, + host: NreplClient.NREPL_HOST, + }); + + return new Promise((resolve, reject) => { + conn.once("connect", async () => { + try { + const result = await operation(conn); + resolve(result); + } catch (err) { + reject(err); + } finally { + conn.end(); + } + }); + + conn.once("error", (err: Error) => { + this.logger.error("nREPL connection error: %s", err); + reject( + new Error( + `Failed to connect to nREPL server at ${NreplClient.NREPL_HOST}:${NreplClient.NREPL_PORT}: ${err.message}` + ) + ); + }); + }); + } + + /** + * Parses the raw nREPL response messages into a structured result. + */ + private parseEvalResult(messages: any[]): NreplEvalResult { + const values: string[] = []; + const outParts: string[] = []; + const errParts: string[] = []; + let ns = "user"; + + for (const msg of messages) { + if (msg.value !== undefined) { + values.push(msg.value); + } + if (msg.out) { + outParts.push(msg.out); + } + if (msg.err) { + errParts.push(msg.err); + } + if (msg.ns) { + ns = msg.ns; + } + if (msg.ex) { + throw new Error(`nREPL evaluation error: ${msg.ex}${msg.err ? "\n" + msg.err : ""}`); + } + } + + return { + values, + out: outParts.join(""), + err: errParts.join(""), + ns, + }; + } +} diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index ec0b150bf7..f29ec36302 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -11,6 +11,8 @@ import { HighLevelOverviewTool } from "./tools/HighLevelOverviewTool"; import { PenpotApiInfoTool } from "./tools/PenpotApiInfoTool"; import { ExportShapeTool } from "./tools/ExportShapeTool"; import { ImportImageTool } from "./tools/ImportImageTool"; +import { EvalCljsExpressionTool } from "./tools/EvalCljsExpressionTool"; +import { NreplClient } from "./NreplClient"; import { ReplServer } from "./ReplServer"; import { ApiDocs } from "./ApiDocs"; @@ -151,6 +153,16 @@ export class PenpotMcpServer { return !this.isRemoteMode(); } + /** + * Indicates whether the server is running in a Penpot development environment. + * + * When enabled (by setting the environment variable PENPOT_MCP_DEVENV to "true"), + * additional developer tools such as ClojureScript expression evaluation are exposed. + */ + public isDevEnv(): boolean { + return process.env.PENPOT_MCP_DEVENV === "true"; + } + /** * Retrieves the high-level overview instructions explaining core Penpot usage. */ @@ -177,6 +189,9 @@ export class PenpotMcpServer { if (this.isFileSystemAccessEnabled()) { toolInstances.push(new ImportImageTool(this)); } + if (this.isDevEnv()) { + toolInstances.push(new EvalCljsExpressionTool(this, new NreplClient())); + } return toolInstances.map((instance) => { this.logger.info(`Registering tool: ${instance.getToolName()}`); @@ -341,6 +356,7 @@ export class PenpotMcpServer { this.app.listen(this.port, this.host, async () => { this.logger.info(`Multi-user mode: ${this.isMultiUserMode()}`); this.logger.info(`Remote mode: ${this.isRemoteMode()}`); + this.logger.info(`DevEnv mode: ${this.isDevEnv()}`); this.logger.info(`Modern Streamable HTTP endpoint: http://${this.host}:${this.port}/mcp`); this.logger.info(`Legacy SSE endpoint: http://${this.host}:${this.port}/sse`); this.logger.info(`WebSocket server URL: ws://${this.host}:${this.webSocketPort}`); diff --git a/mcp/packages/server/src/tools/EvalCljsExpressionTool.ts b/mcp/packages/server/src/tools/EvalCljsExpressionTool.ts new file mode 100644 index 0000000000..324cc1ff08 --- /dev/null +++ b/mcp/packages/server/src/tools/EvalCljsExpressionTool.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; +import { Tool } from "../Tool"; +import "reflect-metadata"; +import type { ToolResponse } from "../ToolResponse"; +import { TextResponse } from "../ToolResponse"; +import { PenpotMcpServer } from "../PenpotMcpServer"; +import { NreplClient } from "../NreplClient"; + +/** + * Arguments for the EvalCljsExpressionTool. + */ +export class EvalCljsExpressionArgs { + static schema = { + expression: z.string().min(1, "Expression cannot be empty"), + }; + + /** + * The ClojureScript expression to evaluate in the frontend runtime. + */ + expression!: string; +} + +/** + * Tool for evaluating ClojureScript expressions in the Penpot frontend runtime. + * + * This tool connects to the shadow-cljs nREPL server and evaluates the given + * ClojureScript expression in the context of the running browser application, + * providing direct access to the frontend application state and APIs. + */ +export class EvalCljsExpressionTool extends Tool { + private readonly nreplClient: NreplClient; + + /** + * Creates a new EvalCljsExpressionTool instance. + * + * @param mcpServer - the MCP server instance + * @param nreplClient - the nREPL client for communicating with shadow-cljs + */ + constructor(mcpServer: PenpotMcpServer, nreplClient: NreplClient) { + super(mcpServer, EvalCljsExpressionArgs.schema); + this.nreplClient = nreplClient; + } + + public getToolName(): string { + return "eval_cljs_expression"; + } + + public getToolDescription(): string { + return ( + "Evaluates a ClojureScript expression in the Penpot frontend runtime via the shadow-cljs nREPL server. " + + "The expression is evaluated in the browser context, providing access to the application state and ClojureScript APIs." + ); + } + + protected async executeCore(args: EvalCljsExpressionArgs): Promise { + const result = await this.nreplClient.evalCljs(args.expression); + + const parts: string[] = []; + if (result.values.length > 0) { + parts.push(result.values.join("\n")); + } + if (result.out) { + parts.push(`stdout:\n${result.out}`); + } + if (result.err) { + parts.push(`stderr:\n${result.err}`); + } + if (parts.length === 0) { + parts.push("nil"); + } + + return new TextResponse(parts.join("\n\n")); + } +} diff --git a/mcp/packages/server/src/types/nrepl-client.d.ts b/mcp/packages/server/src/types/nrepl-client.d.ts new file mode 100644 index 0000000000..79fbd4db4e --- /dev/null +++ b/mcp/packages/server/src/types/nrepl-client.d.ts @@ -0,0 +1,51 @@ +declare module "nrepl-client" { + import type { Socket } from "net"; + + interface NreplConnection extends Socket { + /** + * Evaluates the given Clojure expression on the nREPL server. + * + * @param code - the Clojure expression to evaluate + * @param callback - called with an error or array of response messages + */ + eval(code: string, callback: (err: Error | null, result: NreplMessage[]) => void): void; + + /** + * Sends a raw nREPL message to the server. + */ + send(message: Record, callback: (err: Error | null, result: NreplMessage[]) => void): void; + + /** + * Clones the current session. + */ + clone(callback: (err: Error | null, result: NreplMessage[]) => void): void; + + /** + * Closes the current session. + */ + close(callback: (err: Error | null, result: NreplMessage[]) => void): void; + } + + interface NreplMessage { + id?: string; + session?: string; + ns?: string; + value?: string; + out?: string; + err?: string; + ex?: string; + status?: string[]; + } + + interface ConnectOptions { + port: number; + host?: string; + } + + /** + * Creates a connection to an nREPL server. + */ + function connect(options: ConnectOptions): NreplConnection; + + export default { connect }; +} diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index c59ac34c3f..a6a640ec5e 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: js-yaml: specifier: ^4.1.1 version: 4.1.1 + nrepl-client: + specifier: ^0.3.0 + version: 0.3.0 penpot-mcp: specifier: file:.. version: packages@file:packages @@ -881,6 +884,9 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} + bencode@2.0.3: + resolution: {integrity: sha512-D/vrAD4dLVX23NalHwb8dSvsUsxeRPO8Y7ToKA015JQYq69MLDOMkC0uGZYA/MPpltLO8rt8eqFC2j8DxjTZ/w==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -1221,6 +1227,9 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + nrepl-client@0.3.0: + resolution: {integrity: sha512-EcROXUrzlGHKOdu/E/5WB0OESCI0iGHhdXeYk9cULYtd72eFJrM/Q1umvjTBfKWlT62y76cnyLG/3CmSCqT12w==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2092,6 +2101,8 @@ snapshots: atomic-sleep@1.0.0: {} + bencode@2.0.3: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -2452,6 +2463,11 @@ snapshots: negotiator@1.0.0: {} + nrepl-client@0.3.0: + dependencies: + bencode: 2.0.3 + tree-kill: 1.2.2 + object-assign@4.1.1: {} object-inspect@1.13.4: {} diff --git a/mcp/scripts/start-mcp-devenv b/mcp/scripts/start-mcp-devenv new file mode 100755 index 0000000000..62e957a9eb --- /dev/null +++ b/mcp/scripts/start-mcp-devenv @@ -0,0 +1,6 @@ +#!/bin/sh + +# This starts the MCP server in a configuration for Penpot development +# (assuming devenv) + +PENPOT_MCP_SERVER_HOST=0.0.0.0 PENPOT_MCP_REMOTE_MODE=true PENPOT_MCP_DEVENV=true pnpm run bootstrap