🎉 Add MCP tool for ClojureScript expression evaluation

New tool to evaluate ClojureScript expressions by connecting to the
nREPL service already provided in devenv.

Add dependency 'nrepl-client' and a corresponding client class
as well as types to support this.

Add a new environment variable for 'devenv mode', which enables
the new tool (PENPOT_MCP_DEVENV).
This commit is contained in:
Dominik Jain 2026-04-28 14:53:23 +02:00
parent e1493de777
commit 66d518f15d
10 changed files with 356 additions and 28 deletions

View File

@ -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".

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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<NreplEvalResult> {
this.logger.debug("Evaluating Clojure expression: %s", code);
return this.withConnection((conn) => {
return new Promise<NreplEvalResult>((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<NreplEvalResult> {
// 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<T>(operation: (conn: any) => Promise<T>): Promise<T> {
const conn = nreplClient.connect({
port: NreplClient.NREPL_PORT,
host: NreplClient.NREPL_HOST,
});
return new Promise<T>((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,
};
}
}

View File

@ -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}`);

View File

@ -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<EvalCljsExpressionArgs> {
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<ToolResponse> {
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"));
}
}

View File

@ -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<string, unknown>, 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 };
}

16
mcp/pnpm-lock.yaml generated
View File

@ -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: {}

6
mcp/scripts/start-mcp-devenv Executable file
View File

@ -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