Compare commits

...

8 Commits

Author SHA1 Message Date
Andrey Antukh
a59bf0936a 📎 Add minor enhacements to bootstrap commands on mcp 2026-01-29 13:39:41 +01:00
Andrey Antukh
9b3bfabd0f 📚 Update mcp readme and doc related to pnpm change 2026-01-29 13:21:17 +01:00
Andrey Antukh
b733406cb3 Add the ability to build docker images 2026-01-29 13:12:34 +01:00
Andrey Antukh
292fd57daf Integrate mcp into the penpot build mechanism 2026-01-29 12:21:13 +01:00
Andrey Antukh
ade3924222 Rename mcp python-scripts to types-generator 2026-01-29 12:00:27 +01:00
Andrey Antukh
6dbe960dbc 🎉 Add pixi dependency to the devenv 2026-01-29 12:00:27 +01:00
Andrey Antukh
d8775be937 ⬆️ Update pnpm on repo root package.json 2026-01-29 12:00:24 +01:00
Andrey Antukh
2b019015c0 🎉 Integrate mcp repository 2026-01-29 11:53:15 +01:00
69 changed files with 29946 additions and 3 deletions

View File

@@ -181,7 +181,8 @@ FROM base AS setup-utils
ENV CLJKONDO_VERSION=2026.01.19 \
BABASHKA_VERSION=1.12.208 \
CLJFMT_VERSION=0.15.6
CLJFMT_VERSION=0.15.6 \
PIXI_VERSION=0.63.2
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
@@ -224,6 +225,26 @@ RUN set -ex; \
tar -xf /tmp/babashka.tar.gz; \
rm -rf /tmp/babashka.tar.gz;
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
BINARY_URL="https://github.com/prefix-dev/pixi/releases/download/v$PIXI_VERSION/pixi-aarch64-unknown-linux-musl.tar.gz"; \
;; \
amd64|x86_64) \
BINARY_URL="https://github.com/prefix-dev/pixi/releases/download/v$PIXI_VERSION/pixi-x86_64-unknown-linux-musl.tar.gz"; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
exit 1; \
;; \
esac; \
cd /tmp; \
curl -LfsSo /tmp/pixi.tar.gz ${BINARY_URL}; \
cd /opt/utils/bin; \
tar -xf /tmp/pixi.tar.gz; \
rm -rf /tmp/pixi.tar.gz;
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \

View File

@@ -0,0 +1,58 @@
FROM ubuntu:24.04
LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
NODE_VERSION=v22.21.1 \
DEBIAN_FRONTEND=noninteractive \
PATH=/opt/node/bin:$PATH
RUN set -ex; \
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \
mkdir -p /etc/resolvconf/resolv.conf.d; \
echo "nameserver 127.0.0.11" > /etc/resolvconf/resolv.conf.d/tail; \
apt-get -qq update; \
apt-get -qqy --no-install-recommends install \
curl \
tzdata \
locales \
ca-certificates \
; \
rm -rf /var/lib/apt/lists/*; \
echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen; \
locale-gen; \
find /usr/share/i18n/locales/ -type f ! -name "en_US" ! -name "POSIX" ! -name "C" -delete;
RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \
;; \
amd64|x86_64) \
BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
exit 1; \
;; \
esac; \
curl -LfsSo /tmp/nodejs.tar.gz ${BINARY_URL}; \
mkdir -p /opt/node; \
cd /opt/node; \
tar -xf /tmp/nodejs.tar.gz --strip-components=1; \
chown -R root /opt/node; \
rm -rf /tmp/nodejs.tar.gz; \
corepack enable; \
mkdir -p /opt/penpot; \
chown -R penpot:penpot /opt/penpot;
ARG BUNDLE_PATH="./bundle-mcp/"
COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/mcp/
WORKDIR /opt/penpot/mcp
USER penpot:penpot
RUN ./setup
CMD ["node", "index.js", "--multi-user"]

View File

@@ -215,6 +215,23 @@ function build-frontend-bundle {
echo ">> bundle frontend end";
}
function build-mcp-bundle {
echo ">> bundle mcp start";
mkdir -p ./bundles
local version=$(print-current-version);
local bundle_dir="./bundles/mcp";
build "mcp";
rm -rf $bundle_dir;
mv ./mcp/dist $bundle_dir;
echo $version > $bundle_dir/version.txt;
put-license-file $bundle_dir;
echo ">> bundle mcp end";
}
function build-backend-bundle {
echo ">> bundle backend start";
@@ -309,6 +326,16 @@ function build-exporter-docker-image {
popd;
}
function build-mcp-docker-image {
rsync -avr --delete ./bundles/mcp/ ./docker/images/bundle-mcp/;
pushd ./docker/images;
docker build \
-t penpotapp/mcp:$CURRENT_BRANCH -t penpotapp/mcp:latest \
--build-arg BUNDLE_PATH="./bundle-mcp/" \
-f Dockerfile.mcp .;
popd;
}
function build-storybook-docker-image {
rsync -avr --delete ./bundles/storybook/ ./docker/images/bundle-storybook/;
pushd ./docker/images;
@@ -346,6 +373,7 @@ function usage {
echo "- build-frontend-docker-image Build frontend docker images."
echo "- build-backend-docker-image Build backend docker images."
echo "- build-exporter-docker-image Build exporter docker images."
echo "- build-mcp-docker-image Build exporter docker images."
echo "- build-storybook-docker-image Build storybook docker images."
echo ""
echo "- version Show penpot's version."
@@ -397,6 +425,7 @@ case $1 in
## production builds
build-bundle)
build-frontend-bundle;
build-mcp-bundle;
build-backend-bundle;
build-exporter-bundle;
build-storybook-bundle;
@@ -406,6 +435,10 @@ case $1 in
build-frontend-bundle;
;;
build-mcp-bundle)
build-mcp-bundle;
;;
build-backend-bundle)
build-backend-bundle;
;;
@@ -431,6 +464,7 @@ case $1 in
build-frontend-docker-image
build-backend-docker-image
build-exporter-docker-image
build-mcp-docker-image
build-storybook-docker-image
;;
@@ -445,7 +479,11 @@ case $1 in
build-exporter-docker-image)
build-exporter-docker-image
;;
build-mcp-docker-image)
build-mcp-docker-image
;;
build-storybook-docker-image)
build-storybook-docker-image
;;

11
mcp/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
.idea
node_modules
dist
*.bak
*.orig
temp
*.tsbuildinfo
# Log files
logs/
*.log

7
mcp/.prettierignore Normal file
View File

@@ -0,0 +1,7 @@
*.md
*.json
python-scripts/
.serena/
# auto-generated files
mcp-server/data/api_types.yml

20
mcp/.prettierrc Normal file
View File

@@ -0,0 +1,20 @@
{
"tabWidth": 4,
"overrides": [
{
"files": "*.yml",
"options": {
"tabWidth": 2
}
}
],
"useTabs": false,
"semi": true,
"singleQuote": false,
"quoteProps": "as-needed",
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "always",
"printWidth": 120,
"endOfLine": "auto"
}

1
mcp/.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/cache

View File

@@ -0,0 +1,25 @@
# Code Style and Conventions
## General Principles
- **Object-Oriented Design**: VERY IMPORTANT: Use idiomatic, object-oriented style with explicit abstractions
- **Strategy Pattern**: Prefer explicitly typed interfaces over bare functions for non-trivial functionality
- **Clean Architecture**: Tools implement a common interface for consistent registration and execution
## TypeScript Configuration
- **Strict Mode**: All strict TypeScript options enabled
- **Target**: ES2022
- **Module System**: CommonJS
- **Declaration Files**: Generated with source maps
## Naming Conventions
- **Classes**: PascalCase (e.g., `ExeceuteCodeTool`, `PenpotMcpServer`)
- **Interfaces**: PascalCase (e.g., `Tool`)
- **Methods**: camelCase (e.g., `execute`, `registerTools`)
- **Constants**: camelCase for readonly properties (e.g., `definition`)
- **Files**: PascalCase for classes (e.g., `ExecuteCodeTool.ts`)
## Documentation Style
- **JSDoc**: Use comprehensive JSDoc comments for classes, methods, and interfaces
- **Description Format**: Initial elliptical phrase that defines *what* it is, followed by details
- **Comment Style**: VERY IMPORTANT: Start with lowercase for comments of code blocks (unless lengthy explanation with multiple sentences)

View File

@@ -0,0 +1,91 @@
# Penpot MCP Project Overview - Updated
## Purpose
This project is a Model Context Protocol (MCP) server for Penpot integration. It provides a TypeScript-based server that can be used to extend Penpot's functionality through custom tools with bidirectional WebSocket communication.
## Tech Stack
- **Language**: TypeScript
- **Runtime**: Node.js
- **Framework**: MCP SDK (@modelcontextprotocol/sdk)
- **Build Tool**: TypeScript Compiler (tsc) + esbuild
- **Package Manager**: pnpm
- **WebSocket**: ws library for real-time communication
## Project Structure
```
penpot-mcp/
├── common/ # Shared type definitions
│ ├── src/
│ │ ├── index.ts # Exports for shared types
│ │ └── types.ts # PluginTaskResult, request/response interfaces
│ └── package.json # @penpot-mcp/common package
├── mcp-server/ # Main MCP server implementation
│ ├── src/
│ │ ├── index.ts # Main server entry point
│ │ ├── PenpotMcpServer.ts # Enhanced with request/response correlation
│ │ ├── PluginTask.ts # Now supports result promises
│ │ ├── tasks/ # PluginTask implementations
│ │ └── tools/ # Tool implementations
│ └── package.json # Includes @penpot-mcp/common dependency
├── penpot-plugin/ # Penpot plugin with response capability
│ ├── src/
│ │ ├── main.ts # Enhanced WebSocket handling with response forwarding
│ │ └── plugin.ts # Now sends task responses back to server
│ └── package.json # Includes @penpot-mcp/common dependency
└── prepare-api-docs # Python project for the generation of API docs
```
## Key Tasks
### Adding a new Tool
1. Implement the tool class in `mcp-server/src/tools/` following the `Tool` interface.
IMPORTANT: Do not catch any exceptions in the `executeCore` method. Let them propagate to be handled centrally.
2. Register the tool in `PenpotMcpServer`.
Look at `PrintTextTool` as an example.
Many tools are linked to tasks that are handled in the plugin, i.e. they have an associated `PluginTask` implementation in `mcp-server/src/tasks/`.
### Adding a new PluginTask
1. Implement the input data interface for the task in `common/src/types.ts`.
2. Implement the `PluginTask` class in `mcp-server/src/tasks/`.
3. Implement the corresponding task handler class in the plugin (`penpot-plugin/src/task-handlers/`).
* In the success case, call `task.sendSuccess`.
* In the failure case, just throw an exception, which will be handled centrally!
* Look at `PrintTextTaskHandler` as an example.
4. Register the task handler in `penpot-plugin/src/plugin.ts` in the `taskHandlers` list.
## Key Components
### Enhanced WebSocket Protocol
- **Request Format**: `{id: string, task: string, params: any}`
- **Response Format**: `{id: string, result: {success: boolean, error?: string, data?: any}}`
- **Request/Response Correlation**: Using unique UUIDs for task tracking
- **Timeout Handling**: 30-second timeout with automatic cleanup
- **Type Safety**: Shared definitions via @penpot-mcp/common package
### Core Classes
- **PenpotMcpServer**: Enhanced with pending task tracking and response handling
- **PluginTask**: Now creates result promises that resolve when plugin responds
- **Tool implementations**: Now properly await task completion and report results
- **Plugin handlers**: Send structured responses back to server
### New Features
1. **Bidirectional Communication**: Plugin now responds with success/failure status
2. **Task Result Promises**: Every executePluginTask() sets and returns a promise
3. **Error Reporting**: Failed tasks properly report error messages to tools
4. **Shared Type Safety**: Common package ensures consistency across projects
5. **Timeout Protection**: Tasks don't hang indefinitely (30s limit)
6. **Request Correlation**: Unique IDs match requests to responses
## Task Flow
```
LLM Tool Call → MCP Server → WebSocket (Request) → Plugin → Penpot API
↑ ↓
Tool Response ← MCP Server ← WebSocket (Response) ← Plugin Result
```

View File

@@ -0,0 +1,70 @@
# Suggested Commands
## Development Commands
```bash
# Navigate to MCP server directory
cd penpot/mcp/server
# Install dependencies
pnpm install
# Build the TypeScript project
pnpm run build
# Start the server (production)
pnpm run start
# Start the server in development mode
npm run start:dev
```
## Testing and Development
```bash
# Run TypeScript compiler in watch mode
pnpx tsc --watch
# Check TypeScript compilation without emitting files
pnpx tsc --noEmit
```
## Windows-Specific Commands
```cmd
# Directory navigation
cd penpot/mcp/server
dir # List directory contents
type package.json # Display file contents
# Git operations
git status
git add .
git commit -m "message"
git push
# File operations
copy src\file.ts backup\file.ts # Copy files
del dist\* # Delete files
mkdir new-directory # Create directory
rmdir /s directory # Remove directory recursively
```
## Project Structure Navigation
```bash
# Key directories
cd penpot/mcp/server/src # Source code
cd penpot/mcp/server/src/tools # Tool implementations
cd penpot/mcp/server/src/interfaces # Type definitions
cd penpot/mcp/server/dist # Compiled output
```
## Common Utilities
```cmd
# Search for text in files
findstr /s /i "HelloWorld" *.ts
# Find files by name
dir /s /b *Tool.ts
# Process management
tasklist | findstr node # Find Node.js processes
taskkill /f /im node.exe # Kill Node.js processes
```

View File

@@ -0,0 +1,56 @@
# Task Completion Guidelines
## After Making Code Changes
### 1. Build and Test
```bash
cd mcp-server
npm run build:full # or npm run build for faster bundling only
```
### 2. Verify TypeScript Compilation
```bash
npx tsc --noEmit
```
### 3. Test the Server
```bash
# Start in development mode to test changes
npm run dev
```
### 4. Code Quality Checks
- Ensure all code follows the established conventions
- Verify JSDoc comments are complete and accurate
- Check that error handling is appropriate
- Use clean imports WITHOUT file extensions (esbuild handles resolution)
- Validate that tool interfaces are properly implemented
### 5. Integration Testing
- Test tool registration in the main server
- Verify MCP protocol compliance
- Ensure tool definitions match implementation
## Before Committing Changes
1. **Build Successfully**: `npm run build:full` completes without errors
2. **No TypeScript Errors**: `npx tsc --noEmit` passes
3. **Documentation Updated**: JSDoc comments reflect changes
4. **Tool Registry Updated**: New tools added to `registerTools()` method
5. **Interface Compliance**: All tools implement the `Tool` interface correctly
## File Organization
- Place new tools in `src/tools/` directory
- Update main server registration in `src/index.ts`
- Follow existing naming conventions
## Common Patterns
- All tools must implement the `Tool` interface
- Use readonly properties for tool definitions
- Include comprehensive error handling
- Follow the established documentation style
- Import WITHOUT file extensions (esbuild resolves them automatically)
## Build System
- Uses esbuild for fast bundling and TypeScript for declarations
- Import statements should omit file extensions entirely
- IDE refactoring is safe - no extension-related build failures

130
mcp/.serena/project.yml Normal file
View File

@@ -0,0 +1,130 @@
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: |
IMPORTANT: You use an idiomatic, object-oriented style.
In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions
rather than mere functions (i.e. use the strategy pattern, for example).
Comments:
When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase
clearly defines *what* it is. Any details then follow in subsequent sentences.
When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless
the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is
required for sentences).
# the name by which the project can be referenced within Serena
project_name: "penpot-mcp"
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: utf-8
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# java julia kotlin lua markdown
# matlab nix pascal perl php
# powershell python python_jedi r rego
# ruby ruby_solargraph rust scala swift
# terraform toml typescript typescript_vts vue
# yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- typescript

239
mcp/README.md Normal file
View File

@@ -0,0 +1,239 @@
![mcp-server-cover-github-1](https://github.com/user-attachments/assets/dcd14e63-fecd-424f-9a50-c1b1eafe2a4f)
# Penpot's Official MCP Server
Penpot integrates a LLM layer built on the Model Context Protocol (MCP) via Penpot's Plugin API to interact with a Penpot design file. Penpot's MCP server enables LLMs to perfom data queries, transformation and creation operations.
Penpot's MCP Server is unlike any other you've seen. You get design-to- design, code-to-design and design-code supercharged workflows.
[![Penpot MCP video playlist](https://github.com/user-attachments/assets/204f1d99-ce51-41dd-a5dd-1ef739f8f089)](https://www.youtube.com/playlist?list=PLgcCPfOv5v57SKMuw1NmS0-lkAXevpn10)
## Architecture
The **Penpot MCP Server** exposes tools to AI clients (LLMs), which support the retrieval
of design data as well as the modification and creation of design elements.
The MCP server communicates with Penpot via the dedicated **Penpot MCP Plugin**,
which connects to the MCP server via WebSocket.
This enables the LLM to carry out tasks in the context of a design file by
executing code that leverages the Penpot Plugin API.
The LLM is free to write and execute arbitrary code snippets
within the Penpot Plugin environment to accomplish its tasks.
![Architecture](resources/architecture.png)
This repository thus contains not only the MCP server implementation itself
but also the supporting Penpot MCP Plugin
(see section [Repository Structure](#repository-structure) below).
## Demonstration
[![Video](https://v32155.1blu.de/penpot/PenpotFest2025_thumbnail.png)](https://v32155.1blu.de/penpot/PenpotFest2025.mp4)
## Usage
To use the Penpot MCP server, you must
* run the MCP server and connect your AI client to it,
* run the web server providing the Penpot MCP plugin, and
* open the Penpot MCP plugin in Penpot and connect it to the MCP server.
Follow the steps below to enable the integration.
### Prerequisites
The project requires [Node.js](https://nodejs.org/) (tested with v22).
Following the installation of Node.js, the tools `npm` and `npx` should be
available in your terminal.
### 1. Build & Launch the MCP Server and the Plugin Server
If it's your first execution, install the required dependencies:
```shell
npm install
```
Then build all components and start the two servers:
```shell
npm run bootstrap
```
This bootstrap command will:
* install dependencies for all components (`npm run install:all`)
* build all components (`npm run build:all`)
* start all components (`npm run start:all`)
### 2. Load the Plugin in Penpot and Establish the Connection
> [!NOTE]
> **Browser Connectivity Restrictions**
>
> Starting with Chromium version 142, the private network access (PNA) restrictions have been hardened,
> and when connecting to `localhost` from a web application served from a different origin
> (such as https://design.penpot.app), the connection must explicitly be allowed.
>
> Most Chromium-based browsers (e.g. Chrome, Vivaldi) will display a popup requesting permission
> to access the local network. Be sure to approve the request to allow the connection.
>
> Some browsers take additional security measures, and you may need to disable them.
> For example, in Brave, disable the "Shield" for the Penpot website to allow local network access.
>
> If your browser refuses to connect to the locally served plugin, check its configuration or
> try a different browser (e.g. Firefox) that does not enforce these restrictions.
1. Open Penpot in your browser
2. Navigate to a design file
3. Open the Plugins menu
4. Load the plugin using the development URL (`http://localhost:4400/manifest.json` by default)
5. Open the plugin UI
6. In the plugin UI, click "Connect to MCP server".
The connection status should change from "Not connected" to "Connected to MCP server".
(Check the browser's developer console for WebSocket connection logs.
Check the MCP server terminal for WebSocket connection messages.)
> [!IMPORTANT]
> Do not close the plugin's UI while using the MCP server, as this will close the connection.
### 3. Connect an MCP Client
By default, the server runs on port 4401 and provides:
- **Modern Streamable HTTP endpoint**: `http://localhost:4401/mcp`
- **Legacy SSE endpoint**: `http://localhost:4401/sse`
These endpoints can be used directly by MCP clients that support them.
Simply configure the client to connect the MCP server by providing the respective URL.
When using a client that only supports stdio transport,
a proxy like `mcp-remote` is required.
#### Using a Proxy for stdio Transport
The `mcp-remote` package can proxy stdio transport to HTTP/SSE,
allowing clients that support only stdio to connect to the MCP server indirectly.
1. Install `mcp-remote` globally if you haven't already:
npm install -g mcp-remote
2. Use `mcp-remote` to provide the launch command for your MCP client:
npx -y mcp-remote http://localhost:4401/sse --allow-http
#### Example: Claude Desktop
For Windows and macOS, there is the official [Claude Desktop app](https://claude.ai/download), which you can use as an MCP client.
For Linux, there is an [unofficial community version](https://github.com/aaddrick/claude-desktop-debian).
Since Claude Desktop natively supports only stdio transport, you will need to use a proxy like `mcp-remote`.
Install it as described above.
To add the server to Claude Desktop's configuration, locate the configuration file (or find it via Menu / File / Settings / Developer):
- **Windows**: `%APPDATA%/Claude/claude_desktop_config.json`
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Linux**: `~/.config/Claude/claude_desktop_config.json`
Add a `penpot` entry under `mcpServers` with the following content:
```json
{
"mcpServers": {
"penpot": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:4401/sse", "--allow-http"]
}
}
}
```
After updating the configuration file, restart Claude Desktop completely for the changes to take effect.
> [!IMPORTANT]
> Be sure to fully quit the app for the changes to take effect; closing the window is *not* sufficient.
> To fully terminate the app, choose Menu / File / Quit.
After the restart, you should see the MCP server listed when clicking on the "Search and tools" icon at the bottom
of the prompt input area.
#### Example: Claude Code
To add the Penpot MCP server to a Claude Code project, issue the command
claude mcp add penpot -t http http://localhost:4401/mcp
## Repository Structure
This repository is a monorepo containing four main components:
1. **Common Types** (`common/`):
- Shared TypeScript definitions for request/response protocol
- Ensures type safety across server and plugin components
2. **Penpot MCP Server** (`mcp-server/`):
- Provides MCP tools to LLMs for Penpot interaction
- Runs a WebSocket server accepting connections from the Penpot MCP plugin
- Implements request/response correlation with unique task IDs
- Handles task timeouts and proper error reporting
3. **Penpot MCP Plugin** (`penpot-plugin/`):
- Connects to the MCP server via WebSocket
- Executes tasks in Penpot using the Plugin API
- Sends structured responses back to the server#
4. **Helper Scripts** (`python-scripts/`):
- Python scripts that prepare data for the MCP server (development use)
The core components are written in TypeScript, rendering interactions with the
Penpot Plugin API both natural and type-safe.
## Configuration
The Penpot MCP server can be configured using environment variables. All configuration
options use the `PENPOT_MCP_` prefix for consistency.
### Server Configuration
| Environment Variable | Description | Default |
|------------------------------------|----------------------------------------------------------------------------|--------------|
| `PENPOT_MCP_SERVER_LISTEN_ADDRESS` | Address on which the MCP server listens (binds to) | `localhost` |
| `PENPOT_MCP_SERVER_PORT` | Port for the HTTP/SSE server | `4401` |
| `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` |
| `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` |
### Logging Configuration
| Environment Variable | Description | Default |
|------------------------|------------------------------------------------------|----------|
| `PENPOT_MCP_LOG_LEVEL` | Log level: `trace`, `debug`, `info`, `warn`, `error` | `info` |
| `PENPOT_MCP_LOG_DIR` | Directory for log files | `logs` |
### Plugin Server Configuration
| Environment Variable | Description | Default |
|-------------------------------------------|-----------------------------------------------------------------------------------------|--------------|
| `PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS` | Address on which the plugin web server listens (single address or comma-separated list) | (local only) |
## Beyond Local Execution
The above instructions describe how to run the MCP server and plugin server locally.
We are working on enabling remote deployments of the MCP server, particularly
in [multi-user mode](docs/multi-user-mode.md), where multiple Penpot users will
be able to connect to the same MCP server instance.
To run the server remotely (even for a single user),
you may set the following environment variables to configure the two servers
(MCP server & plugin server) appropriately:
* `PENPOT_MCP_REMOTE_MODE=true`: This ensures that the MCP server is operating
in remote mode, with local file system access disabled.
* `PENPOT_MCP_SERVER_LISTEN_ADDRESS` and `PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS`:
Set these according to your requirements for remote connectivity.
To bind all interfaces, use `0.0.0.0` (use caution in untrusted networks).
* `PENPOT_MCP_SERVER_ADDRESS=<your-address>`: This sets the hostname or IP address
where the MCP server can be reached. The Penpot MCP Plugin uses this to construct
the WebSocket URL as `ws://<your-address>:<port>` (default port: `4402`).

18
mcp/common/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "mcp-common",
"version": "1.0.0",
"description": "Shared type definitions and interfaces for Penpot MCP",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
"scripts": {
"build": "tsc",
"watch": "tsc --watch"
},
"devDependencies": {
"typescript": "^5.0.0"
},
"files": [
"dist/**/*"
]
}

1
mcp/common/src/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from "./types";

85
mcp/common/src/types.ts Normal file
View File

@@ -0,0 +1,85 @@
/**
* Result of a plugin task execution.
*
* Contains the outcome status of a task and any additional result data.
*/
export interface PluginTaskResult<T> {
/**
* Optional result data from the task execution.
*/
data?: T;
}
/**
* Request message sent from server to plugin.
*
* Contains a unique identifier, task name, and parameters for execution.
*/
export interface PluginTaskRequest {
/**
* Unique identifier for request/response correlation.
*/
id: string;
/**
* The name of the task to execute.
*/
task: string;
/**
* The parameters for task execution.
*/
params: any;
}
/**
* Response message sent from plugin back to server.
*
* Contains the original request ID and the execution result.
*/
export interface PluginTaskResponse<T> {
/**
* Unique identifier matching the original request.
*/
id: string;
/**
* Whether the task completed successfully.
*/
success: boolean;
/**
* Optional error message if the task failed.
*/
error?: string;
/**
* The result of the task execution.
*/
data?: T;
}
/**
* Parameters for the executeCode task.
*/
export interface ExecuteCodeTaskParams {
/**
* The JavaScript code to be executed.
*/
code: string;
}
/**
* Result data for the executeCode task.
*/
export interface ExecuteCodeTaskResultData<T> {
/**
* The result of the executed code, if any.
*/
result: T;
/**
* Captured console output during code execution.
*/
log: string;
}

19
mcp/common/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,41 @@
# Multi-User Mode
> [!WARNING]
> Multi-user mode is under development and not yet fully integrated.
> This information is provided for testing purposes only.
The Penpot MCP server supports a multi-user mode, allowing multiple Penpot users
to connect to the same MCP server instance simultaneously.
This supports remote deployments of the MCP server, without requiring each user
to run their own server instance.
## Limitations
Multi-user mode has the limitation that tools which read from or write to
the local file system are not supported, as the server cannot access
the client's file system. This affects the import and export tools.
## Running Components in Multi-User Mode
To run the MCP server and the Penpot MCP plugin in multi-user mode (for testing),
you can use the following command:
```shell
npm run bootstrap:multi-user
```
This will:
* launch the MCP server in multi-user mode (adding the `--multi-user` flag),
* build and launch the Penpot MCP plugin server in multi-user mode.
See the package.json scripts for both `mcp-server` and `penpot-plugin` for details.
In multi-user mode, users are required to be authenticated via a token.
* This token is provided in the URL used to connect to the MCP server,
e.g. `http://localhost:4401/mcp?userToken=USER_TOKEN`.
* The same token must be provided when connecting the Penpot MCP plugin
to the MCP server.
In the future, the token will, most likely be generated by Penpot and
provided to the plugin automatically.
:warning: For now, it is hard-coded in the plugin's source code for testing purposes.

25
mcp/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "mcp-meta",
"version": "1.0.0",
"description": "",
"scripts": {
"install:all": "pnpm -r install",
"build:all": "pnpm -r run build",
"build:all:multi-user": "pnpm -r run build:multi-user",
"start:all": "pnpm -r --parallel run start",
"start:all:multi-user": "pnpm -r --parallel run start:multi-user",
"bootstrap": "pnpm run install:all && pnpm run build:all && pnpm run start:all",
"bootstrap:multi-user": "npm run install:all && npm run build:all:multi-user && pnpm run start:all:multi-user",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot.git"
},
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
"private": true,
"devDependencies": {
"prettier": "^3.0.0"
}
}

24
mcp/plugin/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

21
mcp/plugin/README.md Normal file
View File

@@ -0,0 +1,21 @@
# Penpot MCP Plugin
This project contains a Penpot plugin that accompanies the Penpot MCP server.
It connects to the MCP server via WebSocket, subsequently allowing the MCP
server to execute tasks in Penpot using the Plugin API.
## Setup
1. Install Dependencies
pnpm install
2. Build the Project
pnpm run build
3. Start a Local Development Server
pnpm run start
This will start a local development server at `http://localhost:4400`.

15
mcp/plugin/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Penpot plugin example</title>
</head>
<body>
<button type="button" data-appearance="secondary" data-handler="connect-mcp">Connect to MCP server</button>
<div id="connection-status" style="margin-top: 10px; font-size: 12px; color: #666">Not connected</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

22
mcp/plugin/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "mcp-plugin",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "vite build --watch",
"start:multi-user": "cross-env MULTI_USER_MODE=true vite build --watch",
"build": "tsc && vite build",
"build:multi-user": "tsc && cross-env MULTI_USER_MODE=true vite build"
},
"dependencies": {
"@penpot/plugin-styles": "1.4.1",
"@penpot/plugin-types": "1.4.1"
},
"devDependencies": {
"cross-env": "^7.0.3",
"typescript": "^5.8.3",
"vite": "^7.0.8",
"vite-live-preview": "^0.3.2"
}
}

View File

@@ -0,0 +1,6 @@
{
"name": "Penpot MCP Plugin",
"code": "plugin.js",
"description": "This plugin enables interaction with the Penpot MCP server",
"permissions": ["content:read", "content:write", "library:read", "library:write", "comment:read", "comment:write"]
}

View File

@@ -0,0 +1,394 @@
import { Fill, FlexLayout, GridLayout, Page, Rectangle, Shape } from "@penpot/plugin-types";
export class PenpotUtils {
/**
* Generates an overview structure of the given shape,
* providing its id, name and type, and recursively its children's attributes.
* The `type` field indicates the type in the Penpot API.
* If the shape has a layout system (flex or grid), includes layout information.
*
* @param shape - The root shape to generate the structure from
* @param maxDepth - Optional maximum depth to traverse (leave undefined for unlimited)
* @returns An object representing the shape structure
*/
public static shapeStructure(shape: Shape, maxDepth: number | undefined = undefined): object {
let children = undefined;
if (maxDepth === undefined || maxDepth > 0) {
if ("children" in shape && shape.children) {
children = shape.children.map((child) =>
this.shapeStructure(child, maxDepth === undefined ? undefined : maxDepth - 1)
);
}
}
const result: any = {
id: shape.id,
name: shape.name,
type: shape.type,
children: children,
};
// add layout information if present
if ("flex" in shape && shape.flex) {
const flex: FlexLayout = shape.flex;
result.layout = {
type: "flex",
dir: flex.dir,
rowGap: flex.rowGap,
columnGap: flex.columnGap,
};
} else if ("grid" in shape && shape.grid) {
const grid: GridLayout = shape.grid;
result.layout = {
type: "grid",
rows: grid.rows,
columns: grid.columns,
rowGap: grid.rowGap,
columnGap: grid.columnGap,
};
}
return result;
}
/**
* Finds all shapes that matches the given predicate in the given shape tree.
*
* @param predicate - A function that takes a shape and returns true if it matches the criteria
* @param root - The root shape to start the search from (defaults to penpot.root)
*/
public static findShapes(predicate: (shape: Shape) => boolean, root: Shape | null = penpot.root): Shape[] {
let result = new Array<Shape>();
let find = function (shape: Shape | null) {
if (!shape) {
return;
}
if (predicate(shape)) {
result.push(shape);
}
if ("children" in shape && shape.children) {
for (let child of shape.children) {
find(child);
}
}
};
find(root);
return result;
}
/**
* Finds the first shape that matches the given predicate in the given shape tree.
*
* @param predicate - A function that takes a shape and returns true if it matches the criteria
* @param root - The root shape to start the search from (if null, searches all pages)
*/
public static findShape(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape | null {
let find = function (shape: Shape | null): Shape | null {
if (!shape) {
return null;
}
if (predicate(shape)) {
return shape;
}
if ("children" in shape && shape.children) {
for (let child of shape.children) {
let result = find(child);
if (result) {
return result;
}
}
}
return null;
};
if (root === null) {
const pages = penpot.currentFile?.pages;
if (pages) {
for (let page of pages) {
let result = find(page.root);
if (result) {
return result;
}
}
}
return null;
} else {
return find(root);
}
}
/**
* Finds a shape by its unique ID.
*
* @param id - The unique ID of the shape to find
* @returns The shape with the matching ID, or null if not found
*/
public static findShapeById(id: string): Shape | null {
return this.findShape((shape) => shape.id === id);
}
public static findPage(predicate: (page: Page) => boolean): Page | null {
let page = penpot.currentFile!.pages.find(predicate);
return page || null;
}
public static getPages(): { id: string; name: string }[] {
return penpot.currentFile!.pages.map((page) => ({ id: page.id, name: page.name }));
}
public static getPageById(id: string): Page | null {
return this.findPage((page) => page.id === id);
}
public static getPageByName(name: string): Page | null {
return this.findPage((page) => page.name.toLowerCase() === name.toLowerCase());
}
public static getPageForShape(shape: Shape): Page | null {
for (const page of penpot.currentFile!.pages) {
if (page.getShapeById(shape.id)) {
return page;
}
}
return null;
}
public static generateCss(shape: Shape): string {
const page = this.getPageForShape(shape);
if (!page) {
throw new Error("Shape is not part of any page");
}
penpot.openPage(page);
return penpot.generateStyle([shape], { type: "css", includeChildren: true });
}
/**
* Checks if a child shape is fully contained within its parent's bounds.
* Visual containment means all edges of the child are within the parent's bounding box.
*
* @param child - The child shape to check
* @param parent - The parent shape to check against
* @returns true if child is fully contained within parent bounds, false otherwise
*/
public static isContainedIn(child: Shape, parent: Shape): boolean {
return (
child.x >= parent.x &&
child.y >= parent.y &&
child.x + child.width <= parent.x + parent.width &&
child.y + child.height <= parent.y + parent.height
);
}
/**
* Sets the position of a shape relative to its parent's position.
* This is a convenience method since parentX and parentY are read-only properties.
*
* @param shape - The shape to position
* @param parentX - The desired X position relative to the parent
* @param parentY - The desired Y position relative to the parent
* @throws Error if the shape has no parent
*/
public static setParentXY(shape: Shape, parentX: number, parentY: number): void {
if (!shape.parent) {
throw new Error("Shape has no parent - cannot set parent-relative position");
}
shape.x = shape.parent.x + parentX;
shape.y = shape.parent.y + parentY;
}
/**
* Analyzes all descendants of a shape by applying an evaluator function to each.
* Only descendants for which the evaluator returns a non-null/non-undefined value are included in the result.
* This is a general-purpose utility for validation, analysis, or collecting corrector functions.
*
* @param root - The root shape whose descendants to analyze
* @param evaluator - Function called for each descendant with (root, descendant); return null/undefined to skip
* @param maxDepth - Optional maximum depth to traverse (undefined for unlimited)
* @returns Array of objects containing the shape and the evaluator's result
*/
public static analyzeDescendants<T>(
root: Shape,
evaluator: (root: Shape, descendant: Shape) => T | null | undefined,
maxDepth: number | undefined = undefined
): Array<{ shape: Shape; result: NonNullable<T> }> {
const results: Array<{ shape: Shape; result: NonNullable<T> }> = [];
const traverse = (shape: Shape, currentDepth: number): void => {
const result = evaluator(root, shape);
if (result !== null && result !== undefined) {
results.push({ shape, result: result as NonNullable<T> });
}
if (maxDepth === undefined || currentDepth < maxDepth) {
if ("children" in shape && shape.children) {
for (const child of shape.children) {
traverse(child, currentDepth + 1);
}
}
}
};
// Start traversal with root's children (not root itself)
if ("children" in root && root.children) {
for (const child of root.children) {
traverse(child, 1);
}
}
return results;
}
/**
* Decodes a base64 string to a Uint8Array.
* This is required because the Penpot plugin environment does not provide the atob function.
*
* @param base64 - The base64-encoded string to decode
* @returns The decoded data as a Uint8Array
*/
public static atob(base64: string): Uint8Array {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const lookup = new Uint8Array(256);
for (let i = 0; i < chars.length; i++) {
lookup[chars.charCodeAt(i)] = i;
}
let bufferLength = base64.length * 0.75;
if (base64[base64.length - 1] === "=") {
bufferLength--;
if (base64[base64.length - 2] === "=") {
bufferLength--;
}
}
const bytes = new Uint8Array(bufferLength);
let p = 0;
for (let i = 0; i < base64.length; i += 4) {
const encoded1 = lookup[base64.charCodeAt(i)];
const encoded2 = lookup[base64.charCodeAt(i + 1)];
const encoded3 = lookup[base64.charCodeAt(i + 2)];
const encoded4 = lookup[base64.charCodeAt(i + 3)];
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
}
return bytes;
}
/**
* Imports an image from base64 data into the Penpot design as a Rectangle shape filled with the image.
* The rectangle has the image's original proportions by default.
* Optionally accepts position (x, y) and dimensions (width, height) parameters.
* If only one dimension is provided, the other is calculated to maintain the image's aspect ratio.
*
* This function is used internally by the ImportImageTool in the MCP server.
*
* @param base64 - The base64-encoded image data
* @param mimeType - The MIME type of the image (e.g., "image/png")
* @param name - The name to assign to the newly created rectangle shape
* @param x - The x-coordinate for positioning the rectangle (optional)
* @param y - The y-coordinate for positioning the rectangle (optional)
* @param width - The desired width of the rectangle (optional)
* @param height - The desired height of the rectangle (optional)
*/
public static async importImage(
base64: string,
mimeType: string,
name: string,
x: number | undefined,
y: number | undefined,
width: number | undefined,
height: number | undefined
): Promise<Rectangle> {
// convert base64 to Uint8Array
const bytes = PenpotUtils.atob(base64);
// upload the image data to Penpot
const imageData = await penpot.uploadMediaData(name, bytes, mimeType);
// create a rectangle shape
const rect = penpot.createRectangle();
rect.name = name;
// calculate dimensions
let rectWidth, rectHeight;
const hasWidth = width !== undefined;
const hasHeight = height !== undefined;
if (hasWidth && hasHeight) {
// both width and height provided - use them directly
rectWidth = width;
rectHeight = height;
} else if (hasWidth) {
// only width provided - maintain aspect ratio
rectWidth = width;
rectHeight = rectWidth * (imageData.height / imageData.width);
} else if (hasHeight) {
// only height provided - maintain aspect ratio
rectHeight = height;
rectWidth = rectHeight * (imageData.width / imageData.height);
} else {
// neither provided - use original dimensions
rectWidth = imageData.width;
rectHeight = imageData.height;
}
// set rectangle dimensions
rect.resize(rectWidth, rectHeight);
// set position if provided
if (x !== undefined) {
rect.x = x;
}
if (y !== undefined) {
rect.y = y;
}
// apply the image as a fill
rect.fills = [{ fillOpacity: 1, fillImage: imageData }];
return rect;
}
/**
* Exports the given shape (or its fill) to BASE64 image data.
*
* This function is used internally by the ExportImageTool in the MCP server.
*
* @param shape - The shape whose image data to export
* @param mode - Either "shape" (to export the entire shape, including descendants) or "fill"
* to export the shape's raw fill image data
* @param asSVG - Whether to export as SVG rather than as a pixel image (only supported for mode "shape")
* @returns A byte array containing the exported image data.
* - For mode="shape", it will be PNG or SVG data depending on the value of `asSVG`.
* - For mode="fill", it will be whatever format the fill image is stored in.
*/
public static async exportImage(shape: Shape, mode: "shape" | "fill", asSVG: boolean): Promise<Uint8Array> {
switch (mode) {
case "shape":
return shape.export({ type: asSVG ? "svg" : "png" });
case "fill":
if (asSVG) {
throw new Error("Image fills cannot be exported as SVG");
}
// check whether the shape has the `fills` member
if (!("fills" in shape)) {
throw new Error("Shape with `fills` member is required for fill export mode");
}
// find first fill that has fillImage
const fills: Fill[] = (shape as any).fills;
for (const fill of fills) {
if (fill.fillImage) {
const imageData = fill.fillImage;
return imageData.data();
}
}
throw new Error("No fill with image data found in the shape");
default:
throw new Error(`Unsupported export mode: ${mode}`);
}
}
}

View File

@@ -0,0 +1,77 @@
/**
* Represents a task received from the MCP server in the Penpot MCP plugin
*/
export class Task<TParams = any> {
public isResponseSent: boolean = false;
/**
* @param requestId Unique identifier for the task request
* @param taskType The type of the task to execute
* @param params Task parameters/arguments
*/
constructor(
public requestId: string,
public taskType: string,
public params: TParams
) {}
/**
* Sends a task response back to the MCP server.
*/
protected sendResponse(success: boolean, data: any = undefined, error: any = undefined): void {
if (this.isResponseSent) {
console.error("Response already sent for task:", this.requestId);
return;
}
const response = {
type: "task-response",
response: {
id: this.requestId,
success: success,
data: data,
error: error,
},
};
// Send to main.ts which will forward to MCP server via WebSocket
penpot.ui.sendMessage(response);
console.log("Sent task response:", response);
this.isResponseSent = true;
}
public sendSuccess(data: any = undefined): void {
this.sendResponse(true, data);
}
public sendError(error: string): void {
this.sendResponse(false, undefined, error);
}
}
/**
* Abstract base class for task handlers in the Penpot MCP plugin.
*
* @template TParams - The type of parameters this handler expects
*/
export abstract class TaskHandler<TParams = any> {
/** The task type this handler is responsible for */
abstract readonly taskType: string;
/**
* Checks if this handler can process the given task.
*
* @param task - The task identifier to check
* @returns True if this handler applies to the given task
*/
isApplicableTo(task: Task): boolean {
return this.taskType === task.taskType;
}
/**
* Handles the task with the provided parameters.
*
* @param task - The task to be handled
*/
abstract handle(task: Task<TParams>): Promise<void>;
}

110
mcp/plugin/src/main.ts Normal file
View File

@@ -0,0 +1,110 @@
import "./style.css";
// get the current theme from the URL
const searchParams = new URLSearchParams(window.location.search);
document.body.dataset.theme = searchParams.get("theme") ?? "light";
// Determine whether multi-user mode is enabled based on URL parameters
const isMultiUserMode = searchParams.get("multiUser") === "true";
console.log("Penpot MCP multi-user mode:", isMultiUserMode);
// WebSocket connection management
let ws: WebSocket | null = null;
const statusElement = document.getElementById("connection-status");
/**
* Updates the connection status display element.
*
* @param status - the base status text to display
* @param isConnectedState - whether the connection is in a connected state (affects color)
* @param message - optional additional message to append to the status
*/
function updateConnectionStatus(status: string, isConnectedState: boolean, message?: string): void {
if (statusElement) {
const displayText = message ? `${status}: ${message}` : status;
statusElement.textContent = displayText;
statusElement.style.color = isConnectedState ? "var(--accent-primary)" : "var(--error-700)";
}
}
/**
* Sends a task response back to the MCP server via WebSocket.
*
* @param response - The response containing task ID and result
*/
function sendTaskResponse(response: any): void {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(response));
console.log("Sent response to MCP server:", response);
} else {
console.error("WebSocket not connected, cannot send response");
}
}
/**
* Establishes a WebSocket connection to the MCP server.
*/
function connectToMcpServer(): void {
if (ws?.readyState === WebSocket.OPEN) {
updateConnectionStatus("Already connected", true);
return;
}
try {
let wsUrl = PENPOT_MCP_WEBSOCKET_URL;
if (isMultiUserMode) {
// TODO obtain proper userToken from penpot
const userToken = "dummyToken";
wsUrl += `?userToken=${encodeURIComponent(userToken)}`;
}
ws = new WebSocket(wsUrl);
updateConnectionStatus("Connecting...", false);
ws.onopen = () => {
console.log("Connected to MCP server");
updateConnectionStatus("Connected to MCP server", true);
};
ws.onmessage = (event) => {
console.log("Received from MCP server:", event.data);
try {
const request = JSON.parse(event.data);
// Forward the task request to the plugin for execution
parent.postMessage(request, "*");
} catch (error) {
console.error("Failed to parse WebSocket message:", error);
}
};
ws.onclose = (event: CloseEvent) => {
console.log("Disconnected from MCP server");
const message = event.reason || undefined;
updateConnectionStatus("Disconnected", false, message);
ws = null;
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
// note: WebSocket error events typically don't contain detailed error messages
updateConnectionStatus("Connection error", false);
};
} catch (error) {
console.error("Failed to connect to MCP server:", error);
const message = error instanceof Error ? error.message : undefined;
updateConnectionStatus("Connection failed", false, message);
}
}
document.querySelector("[data-handler='connect-mcp']")?.addEventListener("click", () => {
connectToMcpServer();
});
// Listen plugin.ts messages
window.addEventListener("message", (event) => {
if (event.data.source === "penpot") {
document.body.dataset.theme = event.data.theme;
} else if (event.data.type === "task-response") {
// Forward task response back to MCP server
sendTaskResponse(event.data.response);
}
});

69
mcp/plugin/src/plugin.ts Normal file
View File

@@ -0,0 +1,69 @@
import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler";
import { Task, TaskHandler } from "./TaskHandler";
/**
* Registry of all available task handlers.
*/
const taskHandlers: TaskHandler[] = [new ExecuteCodeTaskHandler()];
// Determine whether multi-user mode is enabled based on build-time configuration
declare const IS_MULTI_USER_MODE: boolean;
const isMultiUserMode = typeof IS_MULTI_USER_MODE !== "undefined" ? IS_MULTI_USER_MODE : false;
// Open the plugin UI (main.ts)
penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}&multiUser=${isMultiUserMode}`, { width: 158, height: 200 });
// Handle messages
penpot.ui.onMessage<string | { id: string; task: string; params: any }>((message) => {
// Handle plugin task requests
if (typeof message === "object" && message.task && message.id) {
handlePluginTaskRequest(message).catch((error) => {
console.error("Error in handlePluginTaskRequest:", error);
});
}
});
/**
* Handles plugin task requests received from the MCP server via WebSocket.
*
* @param request - The task request containing ID, task type and parameters
*/
async function handlePluginTaskRequest(request: { id: string; task: string; params: any }): Promise<void> {
console.log("Executing plugin task:", request.task, request.params);
const task = new Task(request.id, request.task, request.params);
// Find the appropriate handler
const handler = taskHandlers.find((h) => h.isApplicableTo(task));
if (handler) {
try {
// Cast the params to the expected type and handle the task
console.log("Processing task with handler:", handler);
await handler.handle(task);
// check whether a response was sent and send a generic success if not
if (!task.isResponseSent) {
console.warn("Handler did not send a response, sending generic success.");
task.sendSuccess("Task completed without a specific response.");
}
console.log("Task handled successfully:", task);
} catch (error) {
console.error("Error handling task:", error);
const errorMessage = error instanceof Error ? error.message : "Unknown error";
task.sendError(`Error handling task: ${errorMessage}`);
}
} else {
console.error("Unknown plugin task:", request.task);
task.sendError(`Unknown task type: ${request.task}`);
}
}
// Handle theme change in the iframe
penpot.on("themechange", (theme) => {
penpot.ui.sendMessage({
source: "penpot",
type: "themechange",
theme,
});
});

10
mcp/plugin/src/style.css Normal file
View File

@@ -0,0 +1,10 @@
@import "@penpot/plugin-styles/styles.css";
body {
line-height: 1.5;
padding: 10px;
}
p {
margin-block-end: 0.75rem;
}

View File

@@ -0,0 +1,212 @@
import { Task, TaskHandler } from "../TaskHandler";
import { ExecuteCodeTaskParams, ExecuteCodeTaskResultData } from "../../../common/src";
import { PenpotUtils } from "../PenpotUtils.ts";
/**
* Console implementation that captures all log output for code execution.
*
* Provides the same interface as the native console object but appends
* all output to an internal log string that can be retrieved.
*/
class ExecuteCodeTaskConsole {
/**
* Accumulated log output from all console method calls.
*/
private logOutput: string = "";
/**
* Resets the accumulated log output to empty string.
* Should be called before each code execution to start with clean logs.
*/
resetLog(): void {
this.logOutput = "";
}
/**
* Gets the accumulated log output from all console method calls.
* @returns The complete log output as a string
*/
getLog(): string {
return this.logOutput;
}
/**
* Appends a formatted message to the log output.
* @param level - Log level prefix (e.g., "LOG", "WARN", "ERROR")
* @param args - Arguments to log, will be stringified and joined
*/
private appendToLog(level: string, ...args: any[]): void {
const message = args
.map((arg) => (typeof arg === "object" ? JSON.stringify(arg, null, 2) : String(arg)))
.join(" ");
this.logOutput += `[${level}] ${message}\n`;
}
/**
* Logs a message to the captured output.
*/
log(...args: any[]): void {
this.appendToLog("LOG", ...args);
}
/**
* Logs a warning message to the captured output.
*/
warn(...args: any[]): void {
this.appendToLog("WARN", ...args);
}
/**
* Logs an error message to the captured output.
*/
error(...args: any[]): void {
this.appendToLog("ERROR", ...args);
}
/**
* Logs an informational message to the captured output.
*/
info(...args: any[]): void {
this.appendToLog("INFO", ...args);
}
/**
* Logs a debug message to the captured output.
*/
debug(...args: any[]): void {
this.appendToLog("DEBUG", ...args);
}
/**
* Logs a message with trace information to the captured output.
*/
trace(...args: any[]): void {
this.appendToLog("TRACE", ...args);
}
/**
* Logs a table to the captured output (simplified as JSON).
*/
table(data: any): void {
this.appendToLog("TABLE", data);
}
/**
* Starts a timer (simplified implementation that just logs).
*/
time(label?: string): void {
this.appendToLog("TIME", `Timer started: ${label || "default"}`);
}
/**
* Ends a timer (simplified implementation that just logs).
*/
timeEnd(label?: string): void {
this.appendToLog("TIME_END", `Timer ended: ${label || "default"}`);
}
/**
* Logs messages in a group (simplified to just log the label).
*/
group(label?: string): void {
this.appendToLog("GROUP", label || "");
}
/**
* Logs messages in a collapsed group (simplified to just log the label).
*/
groupCollapsed(label?: string): void {
this.appendToLog("GROUP_COLLAPSED", label || "");
}
/**
* Ends the current group (simplified implementation).
*/
groupEnd(): void {
this.appendToLog("GROUP_END", "");
}
/**
* Clears the console (no-op in this implementation since we want to capture logs).
*/
clear(): void {
// intentionally empty - we don't want to clear captured logs
}
/**
* Counts occurrences of calls with the same label (simplified implementation).
*/
count(label?: string): void {
this.appendToLog("COUNT", label || "default");
}
/**
* Resets the count for a label (simplified implementation).
*/
countReset(label?: string): void {
this.appendToLog("COUNT_RESET", label || "default");
}
/**
* Logs an assertion (simplified to just log if condition is false).
*/
assert(condition: boolean, ...args: any[]): void {
if (!condition) {
this.appendToLog("ASSERT", ...args);
}
}
}
/**
* Task handler for executing JavaScript code in the plugin context.
*
* Maintains a persistent context object that preserves state between code executions
* and captures all console output during execution.
*/
export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
readonly taskType = "executeCode";
/**
* Persistent context object that maintains state between code executions.
* Contains the penpot API, storage object, and custom console implementation.
*/
private readonly context: any;
constructor() {
super();
// initialize context, making penpot, penpotUtils, storage and the custom console available
this.context = {
penpot: penpot,
storage: {},
console: new ExecuteCodeTaskConsole(),
penpotUtils: PenpotUtils,
};
}
async handle(task: Task<ExecuteCodeTaskParams>): Promise<void> {
if (!task.params.code) {
task.sendError("executeCode task requires 'code' parameter");
return;
}
this.context.console.resetLog();
const context = this.context;
const code = task.params.code;
let result: any = await (async (ctx) => {
const fn = new Function(...Object.keys(ctx), `return (async () => { ${code} })();`);
return fn(...Object.values(ctx));
})(context);
console.log("Code execution result:", result);
// return result and captured log
let resultData: ExecuteCodeTaskResultData<any> = {
result: result,
log: this.context.console.getLog(),
};
task.sendSuccess(resultData);
}
}

4
mcp/plugin/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
/// <reference types="vite/client" />
declare const IS_MULTI_USER_MODE: boolean;
declare const PENPOT_MCP_WEBSOCKET_URL: string;

24
mcp/plugin/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"typeRoots": ["./node_modules/@types", "./node_modules/@penpot"],
"types": ["plugin-types"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

46
mcp/plugin/vite.config.ts Normal file
View File

@@ -0,0 +1,46 @@
import { defineConfig } from "vite";
import livePreview from "vite-live-preview";
// Debug: Log the environment variables
console.log("MULTI_USER_MODE env:", process.env.MULTI_USER_MODE);
console.log("Will define IS_MULTI_USER_MODE as:", JSON.stringify(process.env.MULTI_USER_MODE === "true"));
const serverAddress = process.env.PENPOT_MCP_SERVER_ADDRESS || "localhost";
const websocketPort = process.env.PENPOT_MCP_WEBSOCKET_PORT || "4402";
const websocketUrl = `ws://${serverAddress}:${websocketPort}`;
console.log("Will define PENPOT_MCP_WEBSOCKET_URL as:", JSON.stringify(websocketUrl));
export default defineConfig({
plugins: [
livePreview({
reload: true,
config: {
build: {
sourcemap: true,
},
},
}),
],
build: {
rollupOptions: {
input: {
plugin: "src/plugin.ts",
index: "./index.html",
},
output: {
entryFileNames: "[name].js",
},
},
},
preview: {
port: 4400,
cors: true,
allowedHosts: process.env.PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS
? process.env.PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS.split(",").map((h) => h.trim())
: [],
},
define: {
IS_MULTI_USER_MODE: JSON.stringify(process.env.MULTI_USER_MODE === "true"),
PENPOT_MCP_WEBSOCKET_URL: JSON.stringify(websocketUrl),
},
});

2665
mcp/pnpm-lock.yaml generated Normal file
View File

File diff suppressed because it is too large Load Diff

6
mcp/pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,6 @@
#shamefullyHoist: true
packages:
- "./common"
- "./server"
- "./plugin"

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

38
mcp/scripts/build Executable file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# NOTE: this script should be called from the parent directory to
# properly work
set -ex
corepack enable;
corepack install;
# Ensure clean working directory
rm -rf dist;
rm -rf node_modules;
rm -rf server/dist;
rm -rf server/node_modules;
pushd types-generator
pixi install;
pixi run python prepare_api_docs.py;
popd
pnpm -r --filter "!mcp-plugin" install;
pnpm -r --filter "mcp-server" run build:multi-user;
rsync -avr server/dist/ ./dist/;
rsync -avr server/data/ ./dist/data/;
cp server/package.json ./dist/;
cp server/pnpm-lock.yaml ./dist/;
cat <<EOF | tee ./dist/setup
#/usr/bin/env bash
set -e;
corepack enable;
corepack install;
pnpm install -P
EOF
chmod +x ./dist/setup;

0
mcp/server/.gitignore vendored Normal file
View File

24
mcp/server/README.md Normal file
View File

@@ -0,0 +1,24 @@
# Penpot MCP Server
A Model Context Protocol (MCP) server that provides Penpot integration
capabilities for AI clients supporting the model context protocol (MCP).
## Setup
1. Install Dependencies
pnpm install
2. Build the Project
pnpm run build
3. Run the Server
pnpm run start
## Penpot Plugin API REPL
The MCP server includes a REPL interface for testing Penpot Plugin API calls.
To use it, connect to the URL reported at startup.

18268
mcp/server/data/api_types.yml Normal file
View File

File diff suppressed because it is too large Load Diff

253
mcp/server/data/prompts.yml Normal file
View File

@@ -0,0 +1,253 @@
# Prompts configuration for Penpot MCP Server
# This file contains various prompts and instructions that can be used by the server
initial_instructions: |
You have access to Penpot tools in order to interact with a Penpot design project directly.
As a precondition, the user must connect the Penpot design project to the MCP server using the Penpot MCP Plugin.
IMPORTANT: When transferring styles from a Penpot design to code, make sure that you strictly adhere to the design.
NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to
non-creative defaults such as white/black if you are lacking information).
# Executing Code
One of your key tools is the `execute_code` tool, which allows you to run JavaScript code using the Penpot Plugin API
directly in the connected project.
VERY IMPORTANT: When writing code, NEVER LOG INFORMATION YOU ARE ALSO RETURNING. It would duplicate the information you receive!
To execute code correctly, you need to understand the Penpot Plugin API. You can retrieve API documentation via
the `penpot_api_info` tool.
This is the full list of types/interfaces in the Penpot API: $api_types
You use the `storage` object extensively to store data and utility functions you define across tool calls.
This allows you to inspect intermediate results while still being able to build on them in subsequent code executions.
# The Structure of Penpot Designs
A Penpot design ultimately consists of shapes.
The type `Shape` is a union type, which encompasses both containers and low-level shapes.
Shapes in a Penpot design are organized hierarchically.
At the top level, a design project contains one or more `Page` objects.
Each `Page` contains a tree of elements. For a given instance `page`, its root shape is `page.root`.
A Page is frequently structured into boards. A `Board` is a high-level grouping element.
A `Group` is a more low-level grouping element used to organize low-level shapes into a logical unit.
Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image`, `Boolean`, and `SvgRaw`.
`ShapeBase` is a base type most shapes build upon.
# Core Shape Properties and Methods
**Type**:
Any given shape contains information on the concrete type via its `type` field.
**Position and Dimensions**:
* The location properties `x` and `y` refer to the top left corner of a shape's bounding box in the absolute (Page) coordinate system.
These are writable - set them directly to position shapes.
* `parentX` and `parentY` (as well as `boardX` and `boardY`) are READ-ONLY computed properties showing position relative to parent/board.
To position relative to parent, use `penpotUtils.setParentXY(shape, parentX, parentY)` or manually set `shape.x = parent.x + parentX`.
* `width` and `height` are READ-ONLY. Use `resize(width, height)` method to change dimensions.
* `bounds` is a READ-ONLY property. Use `x`, `y` with `resize()` to modify shape bounds.
**Other Writable Properties**:
* `name` - Shape name
* `fills`, `strokes` - Styling properties
* `rotation`, `opacity`, `blocked`, `hidden`, `visible`
**Z-Order**:
* The z-order of shapes is determined by the order in the `children` array of the parent shape.
Therefore, when creating shapes that should be on top of each other, add them to the parent in the correct order
(i.e. add background shapes first, then foreground shapes later).
CRITICAL: NEVER use the broken function `appendChild` to achieve this, ALWAYS use `parent.insertChild(parent.children.length, shape)`
* To modify z-order after creation, use these methods: `bringToFront()`, `sendToBack()`, `bringForward()`, `sendBackward()`,
and, for precise control, `setParentIndex(index)` (0-based).
**Modification Methods**:
* `resize(width, height)` - Change dimensions (required for width/height since they're read-only)
* `rotate(angle, center?)` - Rotate shape
* `remove()` - Permanently destroy the shape (use only for deletion, NOT for reparenting)
**Hierarchical Structure**:
* `parent` - The parent shape (null for root shapes)
Note: Hierarchical nesting does not necessarily imply visual containment
* CRITICAL: To add children to a parent shape (e.g. a `Board`):
- ALWAYS use `parent.insertChild(index, shape)` to add a child, e.g. `parent.insertChild(parent.children.length, shape)` to append
- NEVER use `parent.appendChild(shape)` as it is BROKEN and will not insert in a predictable place (except in flex layout boards)
* Reparenting: `newParent.appendChild(shape)` or `newParent.insertChild(index, shape)` will move a shape to new parent
- Automatically removes the shape from its old parent
- Absolute x/y positions are preserved (use `penpotUtils.setParentXY` to adjust relative position)
# Images
The `Image` type is a legacy type. Images are now typically embedded in a `Fill`, with `fillImage` set to an
`ImageData` object, i.e. the `fills` property of of a shape (e.g. a `Rectangle`) will contain a fill where `fillImage` is set.
Use the `export_shape` and `import_image` tools to export and import images.
# Layout Systems
Boards can have layout systems that automatically control the positioning and spacing of their children:
* **Flex Layout**: A flexbox-style layout system
- Add to a board with `board.addFlexLayout(): FlexLayout`; instance then accessibly via `board.flex`;
Check with: `if (board.flex) { ... }`
- Properties: `dir`, `rowGap`, `columnGap`, `alignItems`, `justifyContent`;
- `dir`: "row" | "column" | "row-reverse" | "column-reverse"
- Padding: `topPadding`, `rightPadding`, `bottomPadding`, `leftPadding`, or combined `verticalPadding`, `horizontalPadding`
- When a board has flex layout,
- child positions are controlled by the layout system, not by individual x/y coordinates;
appending or inserting children automatically positions them according to the layout rules.
- CRITICAL: For for dir="column" or dir="row", the order of the `children` array is reversed relative to the visual order!
Therefore, the element that appears first in the array, appears visually at the end (bottom/right) and vice versa.
ALWAYS BEAR IN MIND THAT THE CHILDREN ARRAY ORDER IS REVERSED FOR dir="column" OR dir="row"!
- CRITICAL: The FlexLayout method `board.flex.appendChild` is BROKEN. To append children to a flex layout board such that
they appear visually at the end, ALWAYS use the Board's method `board.appendChild(shape)`; it will insert at the front
of the `children` array for dir="column" or dir="row", which is what you want. So call it in the order of visual appearance.
To insert at a specific index, use `board.insertChild(index, shape)`, bearing in mind the reversed order for dir="column"
or dir="row".
- To modify spacing: adjust `rowGap` and `columnGap` properties, not individual child positions
* **Grid Layout**: A CSS grid-style layout system
- Add to a board with `board.addFlexLayout(): FlexLayout`; instance then accessibly via `board.grid`;
Check with: `if (board.grid) { ... }`
- Properties: `rows`, `columns`, `rowGap`, `columnGap`
- Children are positioned via 1-based row/column indices
- Add to grid via `board.flex.appendChild(shape, row, column)`
- Modify grid positioning after the fact via `shape.layoutCell: LayoutCellProperties`
* When working with boards:
- ALWAYS check if the board has a layout system before attempting to reposition children
- Modify layout properties (gaps, padding) instead of trying to set child x/y positions directly
- Layout systems override manual positioning of children
# Text Elements
The rendered content of `Text` element is given by the `characters` property.
To change the size of the text, change the `fontSize` property; applying `resize()` does NOT change the font size,
it only changes the formal bounding box; if the text does not fit it, it will overflow.
The bounding box is sized automatically as long as the `growType` property is set to "auto-width" or "auto-height".
`resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-*" if you want automatic sizing - otherwise the bounding box will be meaningless, with the text overflowing!
The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box.
# The `penpot` and `penpotUtils` Objects, Exploring Designs
A key object to use in your code is the `penpot` object (which is of type `Penpot`):
* `penpot.selection` provides the list of shapes the user has selected in the Penpot UI.
If it is unclear which elements to work on, you can ask the user to select them for you.
ALWAYS immediately copy the selected shape(s) into `storage`! Do not assume that the selection remains unchanged.
* `penpot.root` provides the root shape of the currently active page.
* Generation of CSS content for elements via `penpot.generateStyle`
* Generation of HTML/SVG content for elements via `penpot.generateMarkup`
For example, to generate CSS for the currently selected elements, you can execute this:
return penpot.generateStyle(penpot.selection, { type: "css", withChildren: true });
CRITICAL: The `penpotUtils` object provides essential utilities - USE THESE INSTEAD OF WRITING YOUR OWN:
* getPages(): { id: string; name: string }[]
* getPageById(id: string): Page | null
* getPageByName(name: string): Page | null
* shapeStructure(shape: Shape, maxDepth: number | undefined = undefined): { id, name, type, children?, layout? }
Generates an overview structure of the given shape.
- children: recursive, limited by maxDepth
- layout: present if shape has flex/grid layout, contains { type: "flex" | "grid", ... }
* findShapeById(id: string): Shape | null
* findShape(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape | null
If no root is provided, search globally (in all pages).
* findShapes(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape[]
* isContainedIn(shape: Shape, container: Shape): boolean
Returns true iff shape is fully within the container's geometric bounds.
Note that a shape's bounds may not always reflect its actual visual content - descendants can overflow; check using analyzeDescendants (see below).
* setParentXY(shape: Shape, parentX: number, parentY: number): void
Sets shape position relative to its parent (since parentX/parentY are read-only)
* analyzeDescendants<T>(root: Shape, evaluator: (root: Shape, descendant: Shape) => T | null | undefined, maxDepth?: number): Array<{ shape: Shape, result: T }>
General-purpose utility for analyzing/validating descendants
Calls evaluator on each descendant; collects non-null/undefined results
Powerful pattern: evaluator can return corrector functions or diagnostic data
General pointers for working with Penpot designs:
* Prefer `penpotUtils` helper functions — avoid reimplementing shape searching.
* To get an overview of a single page, use `penpotUtils.shapeStructure(page.root, 3)`.
Note that `penpot.root` refers to the current page only. When working across pages, first determine the relevant page(s).
* Use `penpotUtils.findShapes()` or `penpotUtils.findShape()` with predicates to locate elements efficiently.
Common tasks - Quick Reference (ALWAYS use penpotUtils for these):
* Find all images:
const images = penpotUtils.findShapes(
shape => shape.type === 'image' || shape.fills?.some(fill => fill.fillImage),
penpot.root
);
* Find text elements:
const texts = penpotUtils.findShapes(shape => shape.type === 'text', penpot.root);
* Find (the first) shape with a given name:
const shape = penpotUtils.findShape(shape => shape.name === 'MyShape');
* Get structure of current selection:
const structure = penpotUtils.shapeStructure(penpot.selection[0]);
* Find shapes in current selection/board:
const shapes = penpotUtils.findShapes(predicate, penpot.selection[0] || penpot.root);
* Validate/analyze descendants (returning corrector functions):
const fixes = penpotUtils.analyzeDescendants(board, (root, shape) => {
const xMod = shape.parentX % 4;
if (xMod !== 0) {
return () => penpotUtils.setParentXY(shape, Math.round(shape.parentX / 4) * 4, shape.parentY);
}
});
fixes.forEach(f => f.result()); // Apply all fixes
* Find containment violations:
const violations = penpotUtils.analyzeDescendants(board, (root, shape) => {
return !penpotUtils.isContainedIn(shape, root) ? 'outside-bounds' : null;
});
Always validate against the root container that is supposed to contain the shapes.
# Visual Inspection of Designs
For many tasks, it can be critical to visually inspect the design. Remember to use the `export_shape` tool for this purpose!
# Revising Designs
* Before applying design changes, ask: "Would a designer consider this appropriate?"
* When dealing with containment issues, ask: Is the parent too small OR is the child too large?
Container sizes are usually intentional, check content first.
* Check for reasonable font sizes and typefaces
# Asset Libraries
Libraries in Penpot are collections of reusable design assets (components, colors, and typographies) that can be shared across files.
They enable design systems and consistent styling across projects.
Each Penpot file has its own local library and can connect to external shared libraries.
Accessing libraries: via `penpot.library` (type: `LibraryContext`):
* `penpot.library.local` (type: `Library`) - The current file's own library
* `penpot.library.connected` (type: `Library[]`) - Array of already-connected external libraries
* `penpot.library.availableLibraries()` (returns: `Promise<LibrarySummary[]>`) - Libraries available to connect
* `penpot.library.connectLibrary(libraryId: string)` (returns: `Promise<Library>`) - Connect a new library
Each `Library` object has:
* `id: string`
* `name: string`
* `components: LibraryComponent[]` - Array of components
* `colors: LibraryColor[]` - Array of colors
* `typographies: LibraryTypography[]` - Array of typographies
Using library components:
* find a component in the library by name:
const component: LibraryComponent = library.components.find(comp => comp.name.includes('Button'));
* create a new instance of the component on the current page:
const instance: Shape = component.instance();
This returns a `Shape` (often a `Board` containing child elements).
After instantiation, modify the instance's properties as desired.
* get the reference to the main component shape:
const mainShape: Shape = component.mainInstance();
Adding assets to a library:
* const newColor: LibraryColor = penpot.library.local.createColor();
newColor.name = 'Brand Primary';
newColor.color = '#0066FF';
* const newTypo: LibraryTypography = penpot.library.local.createTypography();
newTypo.name = 'Heading Large';
// Set typography properties...
* const shapes: Shape[] = [shape1, shape2]; // shapes to include
const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes);
newComponent.name = 'My Button';
--
You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again.

52
mcp/server/package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "mcp-server",
"version": "1.0.0",
"description": "MCP server for Penpot integration",
"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:js-yaml --external:sharp",
"build": "pnpm run build:server && cp -r src/static dist/static",
"build:multi-user": "pnpm run build",
"build:types": "tsc --emitDeclarationOnly --outDir dist",
"start": "node dist/index.js",
"start:multi-user": "node dist/index.js --multi-user",
"start:dev": "node --import ts-node/register src/index.ts",
"start:dev:multi-user": "node --loader ts-node/esm src/index.ts --multi-user"
},
"keywords": [
"mcp",
"penpot",
"server"
],
"author": "",
"license": "MIT",
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.24.0",
"@penpot/mcp-common": "workspace:../common",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"express": "^5.1.0",
"js-yaml": "^4.1.1",
"penpot-mcp": "file:..",
"pino": "^9.10.0",
"pino-pretty": "^13.1.1",
"reflect-metadata": "^0.1.13",
"sharp": "^0.34.5",
"ws": "^8.18.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/express": "^4.17.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.0.0",
"@types/ws": "^8.5.10",
"esbuild": "^0.25.0",
"ts-node": "^10.9.2",
"typescript": "^5.0.0"
},
"ts-node": {
"esm": true
}
}

2840
mcp/server/pnpm-lock.yaml generated Normal file
View File

File diff suppressed because it is too large Load Diff

128
mcp/server/src/ApiDocs.ts Normal file
View File

@@ -0,0 +1,128 @@
import * as yaml from "js-yaml";
import * as fs from "fs";
import * as path from "path";
/**
* Represents a single type/interface defined in the Penpot API
*/
export class ApiType {
private readonly name: string;
private readonly overview: string;
private readonly members: Record<string, Record<string, string>>;
private cachedFullText: string | null = null;
constructor(name: string, overview: string, members: Record<string, Record<string, string>>) {
this.name = name;
this.overview = overview;
this.members = members;
}
/**
* Returns the original name of this API type.
*/
getName(): string {
return this.name;
}
/**
* Returns the overview text of this API type (which all signature/type declarations)
*/
getOverviewText() {
return this.overview;
}
/**
* Creates a single markdown text document from all parts of this API type.
*
* The full text is cached within the object for performance.
*/
getFullText(): string {
if (this.cachedFullText === null) {
let text = this.overview;
for (const [memberType, memberEntries] of Object.entries(this.members)) {
text += `\n\n## ${memberType}\n`;
for (const [memberName, memberDescription] of Object.entries(memberEntries)) {
text += `\n### ${memberName}\n\n${memberDescription}`;
}
}
this.cachedFullText = text;
}
return this.cachedFullText;
}
/**
* Returns the description of the member with the given name.
*
* The member type doesn't matter for the search, as member names are unique
* across all member types within a single API type.
*/
getMember(memberName: string): string | null {
for (const memberEntries of Object.values(this.members)) {
if (memberName in memberEntries) {
return memberEntries[memberName];
}
}
return null;
}
}
/**
* Loads and manages API documentation from YAML files.
*
* This class provides case-insensitive access to API type documentation
* loaded from the data/api_types.yml file.
*/
export class ApiDocs {
private readonly apiTypes: Map<string, ApiType> = new Map();
/**
* Creates a new ApiDocs instance and loads the API types from the YAML file.
*/
constructor() {
this.loadApiTypes();
}
/**
* Loads API types from the data/api_types.yml file.
*/
private loadApiTypes(): void {
const yamlPath = path.join(process.cwd(), "data", "api_types.yml");
const yamlContent = fs.readFileSync(yamlPath, "utf8");
const data = yaml.load(yamlContent) as Record<string, any>;
for (const [typeName, typeData] of Object.entries(data)) {
const overview = typeData.overview || "";
const members = typeData.members || {};
const apiType = new ApiType(typeName, overview, members);
// store with lower-case key for case-insensitive retrieval
this.apiTypes.set(typeName.toLowerCase(), apiType);
}
}
/**
* Retrieves an API type by name (case-insensitive).
*/
getType(typeName: string): ApiType | null {
return this.apiTypes.get(typeName.toLowerCase()) || null;
}
/**
* Returns all available type names.
*/
getTypeNames(): string[] {
return Array.from(this.apiTypes.values()).map((type) => type.getName());
}
/**
* Returns the number of loaded API types.
*/
getTypeCount(): number {
return this.apiTypes.size;
}
}

View File

@@ -0,0 +1,86 @@
import { readFileSync, existsSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import yaml from "js-yaml";
import { createLogger } from "./logger.js";
/**
* Interface defining the structure of the prompts configuration file.
*/
export interface PromptsConfig {
/** Initial instructions displayed when the server starts or connects to a client */
initial_instructions: string;
[key: string]: any; // Allow for future extension with additional prompt types
}
/**
* Configuration loader for prompts and server settings.
*
* Handles loading and parsing of YAML configuration files,
* providing type-safe access to configuration values with
* appropriate fallbacks for missing files or values.
*/
export class ConfigurationLoader {
private readonly logger = createLogger("ConfigurationLoader");
private readonly baseDir: string;
private promptsConfig: PromptsConfig | null = null;
/**
* Creates a new configuration loader instance.
*
* @param baseDir - Base directory for resolving configuration file paths
*/
constructor(baseDir?: string) {
// Default to the directory containing this module
this.baseDir = baseDir || dirname(fileURLToPath(import.meta.url));
}
/**
* Loads the prompts configuration from the YAML file.
*
* Reads and parses the prompts.yml file, providing cached access
* to configuration values on subsequent calls.
*
* @returns The parsed prompts configuration object
*/
public getPromptsConfig(): PromptsConfig {
if (this.promptsConfig !== null) {
return this.promptsConfig;
}
const promptsPath = join(this.baseDir, "..", "data", "prompts.yml");
if (!existsSync(promptsPath)) {
throw new Error(`Prompts configuration file not found at ${promptsPath}, using defaults`);
}
const fileContent = readFileSync(promptsPath, "utf8");
const parsedConfig = yaml.load(fileContent) as PromptsConfig;
this.promptsConfig = parsedConfig || {};
this.logger.info(`Loaded prompts configuration from ${promptsPath}`);
return this.promptsConfig;
}
/**
* Gets the initial instructions for the MCP server.
*
* @returns The initial instructions string, or undefined if not configured
*/
public getInitialInstructions(): string {
const config = this.getPromptsConfig();
return config.initial_instructions;
}
/**
* Reloads the configuration from disk.
*
* Forces a fresh read of the configuration file on the next access,
* useful for development or when configuration files are updated at runtime.
*/
public reloadConfiguration(): void {
this.promptsConfig = null;
this.logger.info("Configuration cache cleared, will reload on next access");
}
}

View File

@@ -0,0 +1,262 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { AsyncLocalStorage } from "async_hooks";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { ExecuteCodeTool } from "./tools/ExecuteCodeTool";
import { PluginBridge } from "./PluginBridge";
import { ConfigurationLoader } from "./ConfigurationLoader";
import { createLogger } from "./logger";
import { Tool } from "./Tool";
import { HighLevelOverviewTool } from "./tools/HighLevelOverviewTool";
import { PenpotApiInfoTool } from "./tools/PenpotApiInfoTool";
import { ExportShapeTool } from "./tools/ExportShapeTool";
import { ImportImageTool } from "./tools/ImportImageTool";
import { ReplServer } from "./ReplServer";
import { ApiDocs } from "./ApiDocs";
/**
* Session context for request-scoped data.
*/
export interface SessionContext {
userToken?: string;
}
export class PenpotMcpServer {
private readonly logger = createLogger("PenpotMcpServer");
private readonly server: McpServer;
private readonly tools: Map<string, Tool<any>>;
public readonly configLoader: ConfigurationLoader;
private app: any;
public readonly pluginBridge: PluginBridge;
private readonly replServer: ReplServer;
private apiDocs: ApiDocs;
/**
* Manages session-specific context, particularly user tokens for each request.
*/
private readonly sessionContext = new AsyncLocalStorage<SessionContext>();
private readonly transports = {
streamable: {} as Record<string, StreamableHTTPServerTransport>,
sse: {} as Record<string, { transport: SSEServerTransport; userToken?: string }>,
};
private readonly port: number;
private readonly webSocketPort: number;
private readonly replPort: number;
private readonly listenAddress: string;
/**
* the address (domain name or IP address) via which clients can reach the MCP server
*/
public readonly serverAddress: string;
constructor(private isMultiUser: boolean = false) {
// read port configuration from environment variables
this.port = parseInt(process.env.PENPOT_MCP_SERVER_PORT ?? "4401", 10);
this.webSocketPort = parseInt(process.env.PENPOT_MCP_WEBSOCKET_PORT ?? "4402", 10);
this.replPort = parseInt(process.env.PENPOT_MCP_REPL_PORT ?? "4403", 10);
this.listenAddress = process.env.PENPOT_MCP_SERVER_LISTEN_ADDRESS ?? "localhost";
this.serverAddress = process.env.PENPOT_MCP_SERVER_ADDRESS ?? "localhost";
this.configLoader = new ConfigurationLoader();
this.apiDocs = new ApiDocs();
this.server = new McpServer(
{
name: "penpot-mcp-server",
version: "1.0.0",
},
{
instructions: this.getInitialInstructions(),
}
);
this.tools = new Map<string, Tool<any>>();
this.pluginBridge = new PluginBridge(this, this.webSocketPort);
this.replServer = new ReplServer(this.pluginBridge, this.replPort);
this.registerTools();
}
/**
* Indicates whether the server is running in multi-user mode,
* where user tokens are required for authentication.
*/
public isMultiUserMode(): boolean {
return this.isMultiUser;
}
/**
* Indicates whether the server is running in remote mode.
*
* In remote mode, the server is not assumed to be accessed only by a local user on the same machine,
* with corresponding limitations being enforced.
* Remote mode can be explicitly enabled by setting the environment variable PENPOT_MCP_REMOTE_MODE
* to "true". Enabling multi-user mode forces remote mode, regardless of the value of the environment
* variable.
*/
public isRemoteMode(): boolean {
const isRemoteModeRequested: boolean = process.env.PENPOT_MCP_REMOTE_MODE === "true";
return this.isMultiUserMode() || isRemoteModeRequested;
}
/**
* Indicates whether file system access is enabled for MCP tools.
* Access is enabled only in local mode, where the file system is assumed
* to belong to the user running the server locally.
*/
public isFileSystemAccessEnabled(): boolean {
return !this.isRemoteMode();
}
public getInitialInstructions(): string {
let instructions = this.configLoader.getInitialInstructions();
instructions = instructions.replace("$api_types", this.apiDocs.getTypeNames().join(", "));
return instructions;
}
/**
* Retrieves the current session context.
*
* @returns The session context for the current request, or undefined if not in a request context
*/
public getSessionContext(): SessionContext | undefined {
return this.sessionContext.getStore();
}
private registerTools(): void {
// Create relevant tool instances (depending on file system access)
const toolInstances: Tool<any>[] = [
new ExecuteCodeTool(this),
new HighLevelOverviewTool(this),
new PenpotApiInfoTool(this, this.apiDocs),
new ExportShapeTool(this), // tool adapts to file system access internally
];
if (this.isFileSystemAccessEnabled()) {
toolInstances.push(new ImportImageTool(this));
}
for (const tool of toolInstances) {
const toolName = tool.getToolName();
this.tools.set(toolName, tool);
// Register each tool with McpServer
this.logger.info(`Registering tool: ${toolName}`);
this.server.registerTool(
toolName,
{
description: tool.getToolDescription(),
inputSchema: tool.getInputSchema(),
},
async (args) => {
return tool.execute(args);
}
);
}
}
private setupHttpEndpoints(): void {
/**
* Modern Streamable HTTP connection endpoint
*/
this.app.all("/mcp", async (req: any, res: any) => {
const userToken = req.query.userToken as string | undefined;
await this.sessionContext.run({ userToken }, async () => {
const { randomUUID } = await import("node:crypto");
const sessionId = req.headers["mcp-session-id"] as string | undefined;
let transport: StreamableHTTPServerTransport;
if (sessionId && this.transports.streamable[sessionId]) {
transport = this.transports.streamable[sessionId];
} else {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id: string) => {
this.transports.streamable[id] = transport;
},
});
transport.onclose = () => {
if (transport.sessionId) {
delete this.transports.streamable[transport.sessionId];
}
};
await this.server.connect(transport);
}
await transport.handleRequest(req, res, req.body);
});
});
/**
* Legacy SSE connection endpoint
*/
this.app.get("/sse", async (req: any, res: any) => {
const userToken = req.query.userToken as string | undefined;
await this.sessionContext.run({ userToken }, async () => {
const transport = new SSEServerTransport("/messages", res);
this.transports.sse[transport.sessionId] = { transport, userToken };
res.on("close", () => {
delete this.transports.sse[transport.sessionId];
});
await this.server.connect(transport);
});
});
/**
* SSE message POST endpoint (using previously established session)
*/
this.app.post("/messages", async (req: any, res: any) => {
const sessionId = req.query.sessionId as string;
const session = this.transports.sse[sessionId];
if (session) {
await this.sessionContext.run({ userToken: session.userToken }, async () => {
await session.transport.handlePostMessage(req, res, req.body);
});
} else {
res.status(400).send("No transport found for sessionId");
}
});
}
async start(): Promise<void> {
const { default: express } = await import("express");
this.app = express();
this.app.use(express.json());
this.setupHttpEndpoints();
return new Promise((resolve) => {
this.app.listen(this.port, this.listenAddress, async () => {
this.logger.info(`Multi-user mode: ${this.isMultiUserMode()}`);
this.logger.info(`Remote mode: ${this.isRemoteMode()}`);
this.logger.info(`Modern Streamable HTTP endpoint: http://${this.serverAddress}:${this.port}/mcp`);
this.logger.info(`Legacy SSE endpoint: http://${this.serverAddress}:${this.port}/sse`);
this.logger.info(`WebSocket server URL: ws://${this.serverAddress}:${this.webSocketPort}`);
// start the REPL server
await this.replServer.start();
resolve();
});
});
}
/**
* Stops the MCP server and associated services.
*
* Gracefully shuts down the REPL server and other components.
*/
public async stop(): Promise<void> {
this.logger.info("Stopping Penpot MCP Server...");
await this.replServer.stop();
this.logger.info("Penpot MCP Server stopped");
}
}

View File

@@ -0,0 +1,227 @@
import { WebSocket, WebSocketServer } from "ws";
import * as http from "http";
import { PluginTask } from "./PluginTask";
import { PluginTaskResponse, PluginTaskResult } from "@penpot/mcp-common";
import { createLogger } from "./logger";
import type { PenpotMcpServer } from "./PenpotMcpServer";
interface ClientConnection {
socket: WebSocket;
userToken: string | null;
}
/**
* Manages WebSocket connections to Penpot plugin instances and handles plugin tasks
* over these connections.
*/
export class PluginBridge {
private readonly logger = createLogger("PluginBridge");
private readonly wsServer: WebSocketServer;
private readonly connectedClients: Map<WebSocket, ClientConnection> = new Map();
private readonly clientsByToken: Map<string, ClientConnection> = new Map();
private readonly pendingTasks: Map<string, PluginTask<any, any>> = new Map();
private readonly taskTimeouts: Map<string, NodeJS.Timeout> = new Map();
constructor(
public readonly mcpServer: PenpotMcpServer,
private port: number,
private taskTimeoutSecs: number = 30
) {
this.wsServer = new WebSocketServer({ port: port });
this.setupWebSocketHandlers();
}
/**
* Sets up WebSocket connection handlers for plugin communication.
*
* Manages client connections and provides bidirectional communication
* channel between the MCP mcpServer and Penpot plugin instances.
*/
private setupWebSocketHandlers(): void {
this.wsServer.on("connection", (ws: WebSocket, request: http.IncomingMessage) => {
// extract userToken from query parameters
const url = new URL(request.url!, `ws://${request.headers.host}`);
const userToken = url.searchParams.get("userToken");
// require userToken if running in multi-user mode
if (this.mcpServer.isMultiUserMode() && !userToken) {
this.logger.warn("Connection attempt without userToken in multi-user mode - rejecting");
ws.close(1008, "Missing userToken parameter");
return;
}
if (userToken) {
this.logger.info("New WebSocket connection established (token provided)");
} else {
this.logger.info("New WebSocket connection established");
}
// register the client connection with both indexes
const connection: ClientConnection = { socket: ws, userToken };
this.connectedClients.set(ws, connection);
if (userToken) {
// ensure only one connection per userToken
if (this.clientsByToken.has(userToken)) {
this.logger.warn("Duplicate connection for given user token; rejecting new connection");
ws.close(1008, "Duplicate connection for given user token; close previous connection first.");
}
this.clientsByToken.set(userToken, connection);
}
ws.on("message", (data: Buffer) => {
this.logger.debug("Received WebSocket message: %s", data.toString());
try {
const response: PluginTaskResponse<any> = JSON.parse(data.toString());
this.handlePluginTaskResponse(response);
} catch (error) {
this.logger.error(error, "Failure while processing WebSocket message");
}
});
ws.on("close", () => {
this.logger.info("WebSocket connection closed");
const connection = this.connectedClients.get(ws);
this.connectedClients.delete(ws);
if (connection?.userToken) {
this.clientsByToken.delete(connection.userToken);
}
});
ws.on("error", (error) => {
this.logger.error(error, "WebSocket connection error");
const connection = this.connectedClients.get(ws);
this.connectedClients.delete(ws);
if (connection?.userToken) {
this.clientsByToken.delete(connection.userToken);
}
});
});
this.logger.info("WebSocket mcpServer started on port %d", this.port);
}
/**
* Handles responses from the plugin for completed tasks.
*
* Finds the pending task by ID and resolves or rejects its promise
* based on the execution result.
*
* @param response - The plugin task response containing ID and result
*/
private handlePluginTaskResponse(response: PluginTaskResponse<any>): void {
const task = this.pendingTasks.get(response.id);
if (!task) {
this.logger.info(`Received response for unknown task ID: ${response.id}`);
return;
}
// Clear the timeout and remove the task from pending tasks
const timeoutHandle = this.taskTimeouts.get(response.id);
if (timeoutHandle) {
clearTimeout(timeoutHandle);
this.taskTimeouts.delete(response.id);
}
this.pendingTasks.delete(response.id);
// Resolve or reject the task's promise based on the result
if (response.success) {
task.resolveWithResult({ data: response.data });
} else {
const error = new Error(response.error || "Task execution failed (details not provided)");
task.rejectWithError(error);
}
this.logger.info(`Task ${response.id} completed: success=${response.success}`);
}
/**
* Determines the client connection to use for executing a task.
*
* In single-user mode, returns the single connected client.
* In multi-user mode, returns the client matching the session's userToken.
*
* @returns The client connection to use
* @throws Error if no suitable connection is found or if configuration is invalid
*/
private getClientConnection(): ClientConnection {
if (this.mcpServer.isMultiUserMode()) {
const sessionContext = this.mcpServer.getSessionContext();
if (!sessionContext?.userToken) {
throw new Error("No userToken found in session context. Multi-user mode requires authentication.");
}
const connection = this.clientsByToken.get(sessionContext.userToken);
if (!connection) {
throw new Error(
`No plugin instance connected for user token. Please ensure the plugin is running and connected with the correct token.`
);
}
return connection;
} else {
// single-user mode: return the single connected client
if (this.connectedClients.size === 0) {
throw new Error(
`No Penpot plugin instances are currently connected. Please ensure the plugin is running and connected.`
);
}
if (this.connectedClients.size > 1) {
throw new Error(
`Multiple (${this.connectedClients.size}) Penpot MCP Plugin instances are connected. ` +
`Ask the user to ensure that only one instance is connected at a time.`
);
}
// return the first (and only) connection
const connection = this.connectedClients.values().next().value;
return <ClientConnection>connection;
}
}
/**
* Executes a plugin task by sending it to connected clients.
*
* Registers the task for result correlation and returns a promise
* that resolves when the plugin responds with the execution result.
*
* @param task - The plugin task to execute
* @throws Error if no plugin instances are connected or available
*/
public async executePluginTask<TResult extends PluginTaskResult<any>>(
task: PluginTask<any, TResult>
): Promise<TResult> {
// get the appropriate client connection based on mode
const connection = this.getClientConnection();
// register the task for result correlation
this.pendingTasks.set(task.id, task);
// send task to the selected client
const requestMessage = JSON.stringify(task.toRequest());
if (connection.socket.readyState !== 1) {
// WebSocket is not open
this.pendingTasks.delete(task.id);
throw new Error(`Plugin instance is disconnected. Task could not be sent.`);
}
connection.socket.send(requestMessage);
// Set up a timeout to reject the task if no response is received
const timeoutHandle = setTimeout(() => {
const pendingTask = this.pendingTasks.get(task.id);
if (pendingTask) {
this.pendingTasks.delete(task.id);
this.taskTimeouts.delete(task.id);
pendingTask.rejectWithError(
new Error(`Task ${task.id} timed out after ${this.taskTimeoutSecs} seconds`)
);
}
}, this.taskTimeoutSecs * 1000);
this.taskTimeouts.set(task.id, timeoutHandle);
this.logger.info(`Sent task ${task.id} to connected client`);
return await task.getResultPromise();
}
}

View File

@@ -0,0 +1,122 @@
/**
* Base class for plugin tasks that are sent over WebSocket.
*
* Each task defines a specific operation for the plugin to execute
* along with strongly-typed parameters.
*
* @template TParams - The strongly-typed parameters for this task
*/
import { PluginTaskRequest, PluginTaskResult } from "@penpot/mcp-common";
import { randomUUID } from "crypto";
/**
* Base class for plugin tasks that are sent over WebSocket.
*
* Each task defines a specific operation for the plugin to execute
* along with strongly-typed parameters and request/response correlation.
*
* @template TParams - The strongly-typed parameters for this task
* @template TResult - The expected result type from task execution
*/
export abstract class PluginTask<TParams = any, TResult extends PluginTaskResult<any> = PluginTaskResult<any>> {
/**
* Unique identifier for request/response correlation.
*/
public readonly id: string;
/**
* The name of the task to execute on the plugin side.
*/
public readonly task: string;
/**
* The parameters for this task execution.
*/
public readonly params: TParams;
/**
* Promise that resolves when the task execution completes.
*/
private readonly result: Promise<TResult>;
/**
* Resolver function for the result promise.
*/
private resolveResult?: (result: TResult) => void;
/**
* Rejector function for the result promise.
*/
private rejectResult?: (error: Error) => void;
/**
* Creates a new plugin task instance.
*
* @param task - The name of the task to execute
* @param params - The parameters for task execution
*/
constructor(task: string, params: TParams) {
this.id = randomUUID();
this.task = task;
this.params = params;
this.result = new Promise<TResult>((resolve, reject) => {
this.resolveResult = resolve;
this.rejectResult = reject;
});
}
/**
* Gets the result promise for this task.
*
* @returns Promise that resolves when the task execution completes
*/
getResultPromise(): Promise<TResult> {
if (!this.result) {
throw new Error("Result promise not initialized");
}
return this.result;
}
/**
* Resolves the task with the given result.
*
* This method should be called when a task response is received
* from the plugin with matching ID.
*
* @param result - The task execution result
*/
resolveWithResult(result: TResult): void {
if (!this.resolveResult) {
throw new Error("Result promise not initialized");
}
this.resolveResult(result);
}
/**
* Rejects the task with the given error.
*
* This method should be called when task execution fails
* or times out.
*
* @param error - The error that occurred during task execution
*/
rejectWithError(error: Error): void {
if (!this.rejectResult) {
throw new Error("Result promise not initialized");
}
this.rejectResult(error);
}
/**
* Serializes the task to a request message for WebSocket transmission.
*
* @returns The request message containing ID, task name, and parameters
*/
toRequest(): PluginTaskRequest {
return {
id: this.id,
task: this.task,
params: this.params,
};
}
}

View File

@@ -0,0 +1,112 @@
import express from "express";
import path from "path";
import { fileURLToPath } from "url";
import { PluginBridge } from "./PluginBridge";
import { ExecuteCodePluginTask } from "./tasks/ExecuteCodePluginTask";
import { createLogger } from "./logger";
/**
* Web-based REPL server for executing code through the PluginBridge.
*
* Provides a REPL-style HTML interface that allows users to input
* JavaScript code and execute it via ExecuteCodePluginTask instances.
* The interface maintains command history, displays logs in &lt;pre&gt; tags,
* and shows results in visually separated blocks.
*/
export class ReplServer {
private readonly logger = createLogger("ReplServer");
private readonly app: express.Application;
private readonly port: number;
private server: any;
constructor(
private readonly pluginBridge: PluginBridge,
port: number = 4403
) {
this.port = port;
this.app = express();
this.setupMiddleware();
this.setupRoutes();
}
/**
* Sets up Express middleware for request parsing and static content.
*/
private setupMiddleware(): void {
this.app.use(express.json());
}
/**
* Sets up HTTP routes for the REPL interface and API endpoints.
*/
private setupRoutes(): void {
// serve the main REPL interface
this.app.get("/", (req, res) => {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const htmlPath = path.join(__dirname, "static", "repl.html");
res.sendFile(htmlPath);
});
// API endpoint for executing code
this.app.post("/execute", async (req, res) => {
try {
const { code } = req.body;
if (!code || typeof code !== "string") {
return res.status(400).json({
error: "Code parameter is required and must be a string",
});
}
const task = new ExecuteCodePluginTask({ code });
const result = await this.pluginBridge.executePluginTask(task);
// extract the result member from ExecuteCodeTaskResultData
const executeResult = result.data?.result;
res.json({
success: true,
result: executeResult,
log: result.data?.log || "",
});
} catch (error) {
this.logger.error(error, "Failed to execute code in REPL");
res.status(500).json({
error: error instanceof Error ? error.message : "Unknown error occurred",
});
}
});
}
/**
* Starts the REPL web server.
*
* Begins listening on the configured port and logs server startup information.
*/
public async start(): Promise<void> {
return new Promise((resolve) => {
this.server = this.app.listen(this.port, () => {
this.logger.info(`REPL server started on port ${this.port}`);
this.logger.info(
`REPL interface URL: http://${this.pluginBridge.mcpServer.serverAddress}:${this.port}`
);
resolve();
});
});
}
/**
* Stops the REPL web server.
*/
public async stop(): Promise<void> {
if (this.server) {
return new Promise((resolve) => {
this.server.close(() => {
this.logger.info("REPL server stopped");
resolve();
});
});
}
}
}

121
mcp/server/src/Tool.ts Normal file
View File

@@ -0,0 +1,121 @@
import { z } from "zod";
import "reflect-metadata";
import { TextResponse, ToolResponse } from "./ToolResponse";
import type { PenpotMcpServer, SessionContext } from "./PenpotMcpServer";
import { createLogger } from "./logger";
/**
* 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> {
private readonly logger = createLogger("Tool");
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.
*/
async execute(args: unknown): Promise<ToolResponse> {
try {
let argsInstance: TArgs = args as TArgs;
this.logger.info("Executing tool: %s; arguments: %s", this.getToolName(), this.formatArgs(argsInstance));
// execute the actual tool logic
let result = await this.executeCore(argsInstance);
this.logger.info("Tool execution completed: %s", this.getToolName());
return result;
} catch (error) {
this.logger.error(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>;
}

View File

@@ -0,0 +1,97 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
type CallToolContent = CallToolResult["content"][number];
type TextItem = Extract<CallToolContent, { type: "text" }>;
type ImageItem = Extract<CallToolContent, { type: "image" }>;
export class TextContent implements TextItem {
[x: string]: unknown;
readonly type = "text" as const;
constructor(public text: string) {}
/**
* @param data - Text data as string or as object (from JSON representation where indices are mapped to character codes)
*/
public static textData(data: string | object): string {
if (typeof data === "object") {
// convert object containing character codes (as obtained from JSON conversion of string) back to string
return String.fromCharCode(...(Object.values(data) as number[]));
} else {
return data;
}
}
}
export class ImageContent implements ImageItem {
[x: string]: unknown;
readonly type = "image" as const;
/**
* @param data - Base64-encoded image data
* @param mimeType - MIME type of the image (e.g., "image/png")
*/
constructor(
public data: string,
public mimeType: string
) {}
/**
* Utility function for ensuring a consistent Uint8Array representation of byte data.
* Input can be either a Uint8Array or an object (as obtained from JSON conversion of Uint8Array
* from the plugin).
*
* @param data - data as Uint8Array or as object (from JSON conversion of Uint8Array)
* @return data as 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[]);
} else {
return data;
}
}
}
export class PNGImageContent extends ImageContent {
/**
* @param data - PNG image data as Uint8Array or as object (from JSON conversion of Uint8Array)
*/
constructor(data: Uint8Array | object) {
let array = ImageContent.byteData(data);
super(Buffer.from(array).toString("base64"), "image/png");
}
}
export class ToolResponse implements CallToolResult {
[x: string]: unknown;
content: CallToolContent[]; // <- IMPORTANT: protocols union
constructor(content: CallToolContent[]) {
this.content = content;
}
}
export class TextResponse extends ToolResponse {
constructor(text: string) {
super([new TextContent(text)]);
}
/**
* Creates a TextResponse from text data given as string or as object (from JSON representation where indices are mapped to
* character codes).
*
* @param data - Text data as string or as object (from JSON representation where indices are mapped to character codes)
*/
public static fromData(data: string | object): TextResponse {
return new TextResponse(TextContent.textData(data));
}
}
export class PNGResponse extends ToolResponse {
/**
* @param data - PNG image data as Uint8Array or as object (from JSON conversion of Uint8Array)
*/
constructor(data: Uint8Array | object) {
super([new PNGImageContent(data)]);
}
}

67
mcp/server/src/index.ts Normal file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env node
import { PenpotMcpServer } from "./PenpotMcpServer";
import { createLogger, logFilePath } from "./logger";
/**
* Entry point for Penpot MCP Server
*
* Creates and starts the MCP server instance, handling any startup errors
* gracefully and ensuring proper process termination.
*
* Configuration via environment variables (see README).
*/
async function main(): Promise<void> {
const logger = createLogger("main");
// log the file path early so it appears before any potential errors
logger.info(`Logging to file: ${logFilePath}`);
try {
const args = process.argv.slice(2);
let multiUser = false; // default to single-user mode
// parse command line arguments
for (let i = 0; i < args.length; i++) {
if (args[i] === "--multi-user") {
multiUser = true;
} else if (args[i] === "--help" || args[i] === "-h") {
logger.info("Usage: node dist/index.js [options]");
logger.info("Options:");
logger.info(" --multi-user Enable multi-user mode (default: single-user)");
logger.info(" --help, -h Show this help message");
logger.info("");
logger.info("Note that configuration is mostly handled through environment variables.");
logger.info("Refer to the README for more information.");
process.exit(0);
}
}
const server = new PenpotMcpServer(multiUser);
await server.start();
// keep the process alive
process.on("SIGINT", async () => {
logger.info("Received SIGINT, shutting down gracefully...");
await server.stop();
process.exit(0);
});
process.on("SIGTERM", async () => {
logger.info("Received SIGTERM, shutting down gracefully...");
await server.stop();
process.exit(0);
});
} catch (error) {
logger.error(error, "Failed to start MCP server");
process.exit(1);
}
}
// Start the server if this file is run directly
if (import.meta.url.endsWith(process.argv[1]) || process.argv[1].endsWith("index.js")) {
main().catch((error) => {
createLogger("main").error(error, "Unhandled error in main");
process.exit(1);
});
}

80
mcp/server/src/logger.ts Normal file
View File

@@ -0,0 +1,80 @@
import pino from "pino";
import { join, resolve } from "path";
/**
* Configuration for log file location and level.
*/
const LOG_DIR = process.env.PENPOT_MCP_LOG_DIR || "logs";
const LOG_LEVEL = process.env.PENPOT_MCP_LOG_LEVEL || "info";
/**
* Generates a timestamped log file name.
*
* @returns Log file name
*/
function generateLogFileName(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
const seconds = String(now.getSeconds()).padStart(2, "0");
return `penpot-mcp-${year}${month}${day}-${hours}${minutes}${seconds}.log`;
}
/**
* Absolute path to the log file being written.
*/
export const logFilePath = resolve(join(LOG_DIR, generateLogFileName()));
/**
* Logger instance configured for both console and file output with metadata.
*
* Both console and file output use pretty formatting for human readability.
* Console output includes colors, while file output is plain text.
*/
export const logger = pino({
level: LOG_LEVEL,
timestamp: pino.stdTimeFunctions.isoTime,
transport: {
targets: [
{
// console transport with pretty formatting
target: "pino-pretty",
level: LOG_LEVEL,
options: {
colorize: true,
translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l",
ignore: "pid,hostname",
messageFormat: "{msg}",
levelFirst: true,
},
},
{
// file transport with pretty formatting (same as console)
target: "pino-pretty",
level: LOG_LEVEL,
options: {
destination: logFilePath,
colorize: false,
translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l",
ignore: "pid,hostname",
messageFormat: "{msg}",
levelFirst: true,
mkdir: true,
},
},
],
},
});
/**
* Creates a child logger with the specified name/origin.
*
* @param name - The name/origin identifier for the logger
* @returns Child logger instance with the specified name
*/
export function createLogger(name: string) {
return logger.child({ name });
}

View File

@@ -0,0 +1,554 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Penpot API REPL</title>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<style>
html,
body {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
body {
font-family: "Consolas", "Monaco", "Lucida Console", monospace;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
padding: 15px;
box-sizing: border-box;
}
.repl-container {
background-color: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 15px;
flex: 1;
overflow-y: auto;
max-width: 1200px;
width: 100%;
margin: 0 auto;
box-sizing: border-box;
}
.repl-entry {
margin-bottom: 20px;
border-bottom: 1px solid #eee;
padding-bottom: 15px;
}
.repl-entry:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.input-section {
margin-bottom: 10px;
}
.input-label {
color: #007acc;
font-weight: bold;
margin-bottom: 5px;
display: block;
}
.code-input {
width: 100%;
min-height: 80px;
font-family: "Consolas", "Monaco", "Lucida Console", monospace;
font-size: 14px;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
resize: vertical;
background-color: white;
box-sizing: border-box;
}
.code-input:read-only {
background-color: #f8f9fa;
border-color: #e9ecef;
color: #6c757d;
}
.code-input:focus {
outline: none;
border-color: #007acc;
box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.1);
}
.execute-btn {
background-color: #007acc;
color: white;
border: none;
padding: 8px 16px;
font-size: 14px;
border-radius: 4px;
cursor: pointer;
margin-top: 10px;
font-family: inherit;
}
.execute-btn:hover {
background-color: #005a9e;
}
.execute-btn:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.output-section {
margin-top: 10px;
}
.output-label {
color: #28a745;
font-size: 12px;
margin-bottom: 5px;
display: block;
}
.log-output {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 10px;
margin-bottom: 10px;
white-space: pre-wrap;
font-family: "Consolas", "Monaco", "Lucida Console", monospace;
font-size: 13px;
color: #495057;
}
.result-output {
background-color: #f0fff4;
border: 1px solid #c3e6cb;
border-radius: 4px;
padding: 10px;
white-space: pre-wrap;
font-family: "Consolas", "Monaco", "Lucida Console", monospace;
font-size: 14px;
color: #155724;
}
.error-output {
background-color: #fff5f5;
border: 1px solid #f5c6cb;
border-radius: 4px;
padding: 10px;
white-space: pre-wrap;
font-family: "Consolas", "Monaco", "Lucida Console", monospace;
font-size: 14px;
color: #721c24;
}
.loading-output {
background-color: #fffbf0;
border: 1px solid #ffeeba;
border-radius: 4px;
padding: 10px;
font-family: "Consolas", "Monaco", "Lucida Console", monospace;
font-size: 14px;
color: #856404;
font-style: italic;
}
.controls-hint {
text-align: center;
color: #666;
font-size: 12px;
padding: 10px 0;
font-style: italic;
flex-shrink: 0;
}
.entry-number {
color: #666;
font-size: 12px;
margin-bottom: 5px;
}
.history-indicator {
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
padding: 4px 10px;
font-size: 12px;
color: #856404;
display: inline-block;
margin-bottom: 8px;
}
</style>
</head>
<body>
<div class="repl-container" id="repl-container">
<!-- REPL entries will be dynamically added here -->
</div>
<div class="controls-hint">Ctrl+Enter to execute • Arrow up/down for command history</div>
<script>
$(document).ready(function () {
let isExecuting = false;
let entryCounter = 1;
let commandHistory = []; // full history of executed commands
let historyIndex = 0; // current position in history
let isBrowsingHistory = false; // whether we are currently browsing history
let tempInput = ""; // temporary storage for current input when browsing history
// create the initial input entry
createNewEntry();
function createNewEntry() {
const entryId = `entry-${entryCounter}`;
const isFirstEntry = entryCounter === 1;
const placeholder = isFirstEntry
? `// Enter your JavaScript code here...
console.log('Hello from Penpot!');
return 'This will be the result';`
: "";
const entryHtml = `
<div class="repl-entry" id="${entryId}">
<div class="entry-number">In [${entryCounter}]:</div>
<div class="input-section">
<textarea class="code-input" id="code-input-${entryCounter}"
placeholder="${placeholder}"></textarea>
<button class="execute-btn" id="execute-btn-${entryCounter}">Execute Code</button>
</div>
</div>
`;
$("#repl-container").append(entryHtml);
// bind events for this entry
bindEntryEvents(entryCounter);
// focus on the new input without scrolling
const $input = $(`#code-input-${entryCounter}`);
$input[0].focus({ preventScroll: true });
// auto-resize textarea on input
$input.on("input", function () {
autoResizeTextarea(this);
});
entryCounter++;
}
/**
* Resizes a textarea to fit its content, with a minimum height.
* Adds border height since scrollHeight excludes borders but box-sizing: border-box includes them.
*/
function autoResizeTextarea(textarea) {
textarea.style.height = "auto";
// add 2px for top and bottom border (1px each)
textarea.style.height = Math.max(80, textarea.scrollHeight + 2) + "px";
}
/**
* Checks if the cursor is at the beginning of a textarea (position 0 with no selection).
*/
function isCursorAtBeginning(textarea) {
return textarea.selectionStart === 0 && textarea.selectionEnd === 0;
}
/**
* Checks if the cursor is at the end of a textarea (position at text length with no selection).
*/
function isCursorAtEnd(textarea) {
const len = textarea.value.length;
return textarea.selectionStart === len && textarea.selectionEnd === len;
}
/**
* Navigates through command history for the given entry's textarea.
* @param direction -1 for previous (up), +1 for next (down)
* @param entryNum the entry number
*/
function navigateHistory(direction, entryNum) {
const $codeInput = $(`#code-input-${entryNum}`);
const textarea = $codeInput[0];
if (commandHistory.length === 0) return;
if (direction === -1) {
// going back in history (arrow up)
if (!isBrowsingHistory) {
// starting to browse history: save current input
tempInput = $codeInput.val();
isBrowsingHistory = true;
historyIndex = commandHistory.length - 1;
} else if (historyIndex > 0) {
// go further back in history
historyIndex--;
} else {
// already at oldest entry, do nothing
return;
}
$codeInput.val(commandHistory[historyIndex]);
autoResizeTextarea(textarea);
// keep cursor at beginning for continued history navigation
textarea.setSelectionRange(0, 0);
// show history position (1 = most recent)
const position = commandHistory.length - historyIndex;
showHistoryIndicator(entryNum, position, commandHistory.length);
} else {
// going forward in history (arrow down)
if (!isBrowsingHistory) {
// not browsing history, do nothing
return;
} else if (historyIndex >= commandHistory.length - 1) {
// at most recent entry, return to original input
isBrowsingHistory = false;
$codeInput.val(tempInput);
autoResizeTextarea(textarea);
// cursor at beginning (same as when we entered history)
textarea.setSelectionRange(0, 0);
hideHistoryIndicator();
} else {
// go forward in history
historyIndex++;
$codeInput.val(commandHistory[historyIndex]);
autoResizeTextarea(textarea);
// keep cursor at beginning
textarea.setSelectionRange(0, 0);
// update history position indicator
const position = commandHistory.length - historyIndex;
showHistoryIndicator(entryNum, position, commandHistory.length);
}
}
}
/**
* Exits history browsing mode, keeping current content in the input.
* Moves cursor to end of input.
* @param entryNum the entry number (optional, cursor not moved if not provided)
*/
function exitHistoryBrowsing(entryNum) {
if (isBrowsingHistory) {
isBrowsingHistory = false;
hideHistoryIndicator();
if (entryNum !== undefined) {
const textarea = $(`#code-input-${entryNum}`)[0];
const len = textarea.value.length;
textarea.setSelectionRange(len, len);
}
}
}
/**
* Scrolls the repl container to show the output section of the given entry.
*/
function scrollToOutput($entry) {
const $container = $("#repl-container");
const $outputSection = $entry.find(".output-section");
if ($outputSection.length) {
const containerTop = $container.offset().top;
const outputTop = $outputSection.offset().top;
const scrollTop = $container.scrollTop();
$container.animate(
{
scrollTop: scrollTop + (outputTop - containerTop),
},
300
);
}
}
/**
* Shows or updates the history indicator for the current entry.
* @param entryNum the entry number
* @param position 1-based position from most recent (1 = most recent)
* @param total total number of history items
*/
function showHistoryIndicator(entryNum, position, total) {
const $entry = $(`#entry-${entryNum}`);
let $indicator = $entry.find(".history-indicator");
if ($indicator.length === 0) {
$entry.find(".input-section").before('<div class="history-indicator"></div>');
$indicator = $entry.find(".history-indicator");
}
$indicator.text(`History item ${position}/${total}`);
}
/**
* Hides the history indicator.
*/
function hideHistoryIndicator() {
$(".history-indicator").remove();
}
function bindEntryEvents(entryNum) {
const $executeBtn = $(`#execute-btn-${entryNum}`);
const $codeInput = $(`#code-input-${entryNum}`);
// bind execute button click
$executeBtn.on("click", () => executeCode(entryNum));
// bind keyboard shortcuts
$codeInput.on("keydown", function (e) {
// Ctrl+Enter to execute
if (e.ctrlKey && e.key === "Enter") {
e.preventDefault();
exitHistoryBrowsing(entryNum);
executeCode(entryNum);
return;
}
// arrow up at beginning of input (or while browsing history): navigate to previous history entry
if (e.key === "ArrowUp" && (isBrowsingHistory || isCursorAtBeginning(this))) {
e.preventDefault();
navigateHistory(-1, entryNum);
return;
}
// arrow down at end of input (or while browsing history): navigate to next history entry
if (e.key === "ArrowDown" && (isBrowsingHistory || isCursorAtEnd(this))) {
e.preventDefault();
navigateHistory(+1, entryNum);
return;
}
// any key except pure modifier keys exits history browsing
if (isBrowsingHistory) {
const isModifierOnly = ["Shift", "Control", "Alt", "Meta"].includes(e.key);
if (!isModifierOnly) {
exitHistoryBrowsing();
}
}
});
}
function setExecuting(entryNum, executing) {
isExecuting = executing;
$(`#execute-btn-${entryNum}`).prop("disabled", executing);
$(`#execute-btn-${entryNum}`).text(executing ? "Executing..." : "Execute Code");
}
function displayResult(entryNum, data, isError = false) {
const $entry = $(`#entry-${entryNum}`);
// remove any existing output
$entry.find(".output-section").remove();
// create output section
const outputHtml = `
<div class="output-section">
<div class="output-label">Out [${entryNum}]:</div>
<div class="output-content"></div>
</div>
`;
$entry.append(outputHtml);
const $outputContent = $entry.find(".output-content");
if (isError) {
const errorHtml = `<div class="error-output">Error: ${data.error}</div>`;
$outputContent.html(errorHtml);
} else {
let outputElements = "";
// add log output if present
if (data.log && data.log.trim()) {
outputElements += `<div class="log-output">${escapeHtml(data.log)}</div>`;
}
// add result output
let resultText;
if (data.result !== undefined) {
resultText = JSON.stringify(data.result, null, 2);
} else {
resultText = "(no return value)";
}
outputElements += `<div class="result-output">${escapeHtml(resultText)}</div>`;
$outputContent.html(outputElements);
}
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function executeCode(entryNum) {
if (isExecuting) return;
const $codeInput = $(`#code-input-${entryNum}`);
const code = $codeInput.val().trim();
if (!code) {
displayResult(entryNum, { error: "Please enter some code to execute" }, true);
return;
}
setExecuting(entryNum, true);
// show loading state
const $entry = $(`#entry-${entryNum}`);
$entry.find(".output-section").remove();
const loadingHtml = `
<div class="output-section">
<div class="output-label">Out [${entryNum}]:</div>
<div class="loading-output">Executing code...</div>
</div>
`;
$entry.append(loadingHtml);
$.ajax({
url: "/execute",
method: "POST",
contentType: "application/json",
data: JSON.stringify({ code: code }),
success: function (data) {
displayResult(entryNum, data, false);
// make the textarea read-only and remove the execute button
$codeInput.prop("readonly", true);
$(`#execute-btn-${entryNum}`).remove();
// store the code in history
commandHistory.push(code);
isBrowsingHistory = false; // reset history navigation
tempInput = ""; // clear temporary input
// create a new entry for the next input
createNewEntry();
// scroll to the output section of the executed entry
scrollToOutput($entry);
},
error: function (xhr) {
let errorData;
try {
errorData = JSON.parse(xhr.responseText);
} catch {
errorData = { error: "Network error or invalid response" };
}
displayResult(entryNum, errorData, true);
// scroll to the error output
scrollToOutput($entry);
},
complete: function () {
setExecuting(entryNum, false);
},
});
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,22 @@
import { PluginTask } from "../PluginTask";
import { ExecuteCodeTaskParams, ExecuteCodeTaskResultData, PluginTaskResult } from "@penpot/mcp-common";
/**
* Task for executing JavaScript code in the plugin context.
*
* This task instructs the plugin to execute arbitrary JavaScript code
* and return the result of execution.
*/
export class ExecuteCodePluginTask extends PluginTask<
ExecuteCodeTaskParams,
PluginTaskResult<ExecuteCodeTaskResultData<any>>
> {
/**
* Creates a new execute code task.
*
* @param params - The parameters containing the code to execute
*/
constructor(params: ExecuteCodeTaskParams) {
super("executeCode", params);
}
}

View File

@@ -0,0 +1,77 @@
import { z } from "zod";
import { Tool } from "../Tool";
import type { ToolResponse } from "../ToolResponse";
import { TextResponse } from "../ToolResponse";
import "reflect-metadata";
import { PenpotMcpServer } from "../PenpotMcpServer";
import { ExecuteCodePluginTask } from "../tasks/ExecuteCodePluginTask";
import { ExecuteCodeTaskParams } from "@penpot/mcp-common";
/**
* Arguments class for ExecuteCodeTool
*/
export class ExecuteCodeArgs {
static schema = {
code: z
.string()
.min(1, "Code cannot be empty")
.describe("The JavaScript code to execute in the plugin context."),
};
/**
* The JavaScript code to execute in the plugin context.
*/
code!: string;
}
/**
* Tool for executing JavaScript code in the Penpot plugin context
*/
export class ExecuteCodeTool extends Tool<ExecuteCodeArgs> {
/**
* Creates a new ExecuteCode tool instance.
*
* @param mcpServer - The MCP server instance
*/
constructor(mcpServer: PenpotMcpServer) {
super(mcpServer, ExecuteCodeArgs.schema);
}
public getToolName(): string {
return "execute_code";
}
public getToolDescription(): string {
return (
"Executes JavaScript code in the Penpot plugin context.\n" +
"IMPORTANT: Before using this tool, make sure you have read the 'Penpot High-Level Overview' and know " +
"which Penpot API functionality is necessary and how to use it.\n" +
"You have access two main objects: `penpot` (the Penpot API, of type `Penpot`), `penpotUtils`, " +
"and `storage`.\n" +
"`storage` is an object in which arbitrary data can be stored, simply by adding a new attribute; " +
"stored attributes can be referenced in future calls to this tool, so any intermediate results that " +
"could come in handy later should be stored in `storage` instead of just a fleeting variable; " +
"you can also store functions and thus build up a library).\n" +
"Think of the code being executed as the body of a function: " +
"The tool call returns whatever you return in the applicable `return` statement, if any.\n" +
"If an exception occurs, the exception's message will be returned to you.\n" +
"Any output that you generate via the `console` object will be returned to you separately; so you may use it" +
"to track what your code is doing, but you should *only* do so only if there is an ACTUAL NEED for this! " +
"VERY IMPORTANT: Don't use logging prematurely! NEVER log the data you are returning, as you will otherwise receive it twice!\n" +
"VERY IMPORTANT: In general, try a simple approach first, and only if it fails, try more complex code that involves " +
"handling different cases (in particular error cases) and that applies logging."
);
}
protected async executeCore(args: ExecuteCodeArgs): Promise<ToolResponse> {
const taskParams: ExecuteCodeTaskParams = { code: args.code };
const task = new ExecuteCodePluginTask(taskParams);
const result = await this.mcpServer.pluginBridge.executePluginTask(task);
if (result.data !== undefined) {
return new TextResponse(JSON.stringify(result.data, null, 2));
} else {
return new TextResponse("Code executed successfully with no return value.");
}
}
}

View File

@@ -0,0 +1,147 @@
import { z } from "zod";
import { Tool } from "../Tool";
import { ImageContent, PNGImageContent, PNGResponse, TextContent, TextResponse, ToolResponse } from "../ToolResponse";
import "reflect-metadata";
import { PenpotMcpServer } from "../PenpotMcpServer";
import { ExecuteCodePluginTask } from "../tasks/ExecuteCodePluginTask";
import { FileUtils } from "../utils/FileUtils";
import sharp from "sharp";
/**
* Arguments class for ExportShapeTool
*/
export class ExportShapeArgs {
static schema = {
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'."),
mode: z
.enum(["shape", "fill"])
.default("shape")
.describe(
"The export mode: either 'shape' (full shape as it appears in the design, including descendants; the default) or " +
"'fill' (export the raw image that is used as a fill for the shape; PNG format only)"
),
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."
),
};
shapeId!: string;
format: "svg" | "png" = "png";
mode: "shape" | "fill" = "shape";
filePath?: string;
}
/**
* Tool for executing JavaScript code in the Penpot plugin context
*/
export class ExportShapeTool extends Tool<ExportShapeArgs> {
/**
* Creates a new ExecuteCode tool instance.
*
* @param mcpServer - The MCP server instance
*/
constructor(mcpServer: PenpotMcpServer) {
let schema: any = ExportShapeArgs.schema;
if (!mcpServer.isFileSystemAccessEnabled()) {
// remove filePath key from schema
schema = { ...schema };
delete schema.filePath;
}
super(mcpServer, schema);
}
public getToolName(): string {
return "export_shape";
}
public getToolDescription(): string {
let description =
"Exports a shape (or a shape's image fill) from the Penpot design to a PNG or SVG image, " +
"such that you can get an impression of what it looks like. ";
if (this.mcpServer.isFileSystemAccessEnabled()) {
description += "\nAlternatively, you can save it to a file.";
}
return description;
}
protected async executeCore(args: ExportShapeArgs): Promise<ToolResponse> {
// check arguments
if (args.filePath) {
FileUtils.checkPathIsAbsolute(args.filePath);
}
// create code for exporting the shape
let shapeCode: string;
if (args.shapeId === "selection") {
shapeCode = `penpot.selection[0]`;
} else {
shapeCode = `penpotUtils.findShapeById("${args.shapeId}")`;
}
const asSvg = args.format === "svg";
const code = `return penpotUtils.exportImage(${shapeCode}, "${args.mode}", ${asSvg});`;
// execute the code and obtain the image data
const task = new ExecuteCodePluginTask({ code: code });
const result = await this.mcpServer.pluginBridge.executePluginTask(task);
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(await this.toPngImageBytes(imageData));
} else {
return TextResponse.fromData(imageData);
}
} else {
// save to file requested: make sure file system access is enabled
if (!this.mcpServer.isFileSystemAccessEnabled()) {
throw new Error("File system access is not enabled on the MCP server!");
}
// save to file
if (args.format === "png") {
FileUtils.writeBinaryFile(args.filePath, await this.toPngImageBytes(imageData));
} else {
FileUtils.writeTextFile(args.filePath, TextContent.textData(imageData));
}
return new TextResponse(`The shape has been exported to ${args.filePath}`);
}
}
/**
* Converts image data to PNG format if necessary.
*
* @param data - The original image data as Uint8Array or as object (from JSON conversion of Uint8Array)
* @return The image data as PNG bytes
*/
private async toPngImageBytes(data: Uint8Array | object): Promise<Uint8Array> {
const originalBytes = ImageContent.byteData(data);
// use sharp to detect format and convert to PNG if necessary
const image = sharp(originalBytes);
const metadata = await image.metadata();
// if already PNG, return as-is to avoid unnecessary re-encoding
if (metadata.format === "png") {
return originalBytes;
}
// convert to PNG
const pngBuffer = await image.png().toBuffer();
return new Uint8Array(pngBuffer);
}
}

View File

@@ -0,0 +1,26 @@
import { EmptyToolArgs, Tool } from "../Tool";
import "reflect-metadata";
import type { ToolResponse } from "../ToolResponse";
import { TextResponse } from "../ToolResponse";
import { PenpotMcpServer } from "../PenpotMcpServer";
export class HighLevelOverviewTool extends Tool<EmptyToolArgs> {
constructor(mcpServer: PenpotMcpServer) {
super(mcpServer, EmptyToolArgs.schema);
}
public getToolName(): string {
return "high_level_overview";
}
public getToolDescription(): string {
return (
"Returns basic high-level instructions on the usage of Penpot-related tools and the Penpot API. " +
"If you have already read the 'Penpot High-Level Overview', you must not call this tool."
);
}
protected async executeCore(args: EmptyToolArgs): Promise<ToolResponse> {
return new TextResponse(this.mcpServer.getInitialInstructions());
}
}

View File

@@ -0,0 +1,123 @@
import { z } from "zod";
import { Tool } from "../Tool";
import { TextResponse, ToolResponse } from "../ToolResponse";
import "reflect-metadata";
import { PenpotMcpServer } from "../PenpotMcpServer";
import { ExecuteCodePluginTask } from "../tasks/ExecuteCodePluginTask";
import { FileUtils } from "../utils/FileUtils";
import * as fs from "fs";
import * as path from "path";
/**
* Arguments class for ImportImageTool
*/
export class ImportImageArgs {
static schema = {
filePath: z.string().min(1, "filePath cannot be empty").describe("Absolute path to the image file to import."),
x: z.number().optional().describe("Optional X coordinate for the rectangle's position."),
y: z.number().optional().describe("Optional Y coordinate for the rectangle's position."),
width: z
.number()
.positive("width must be positive")
.optional()
.describe(
"Optional width for the rectangle. If only width is provided, height is calculated to maintain aspect ratio."
),
height: z
.number()
.positive("height must be positive")
.optional()
.describe(
"Optional height for the rectangle. If only height is provided, width is calculated to maintain aspect ratio."
),
};
filePath!: string;
x?: number;
y?: number;
width?: number;
height?: number;
}
/**
* Tool for importing a raster image from the local file system into Penpot
*/
export class ImportImageTool extends Tool<ImportImageArgs> {
/**
* Maps file extensions to MIME types.
*/
protected static readonly MIME_TYPES: { [key: string]: string } = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
};
/**
* Creates a new ImportImage tool instance.
*
* @param mcpServer - The MCP server instance
*/
constructor(mcpServer: PenpotMcpServer) {
super(mcpServer, ImportImageArgs.schema);
}
public getToolName(): string {
return "import_image";
}
public getToolDescription(): string {
return (
"Imports a pixel image from the local file system into Penpot by creating a Rectangle instance " +
"that uses the image as a fill. The rectangle has the image's original proportions by default. " +
"Optionally accepts position (x, y) and dimensions (width, height) parameters. " +
"If only one dimension is provided, the other is calculated to maintain the image's aspect ratio. " +
"Supported formats: JPEG, PNG, GIF, WEBP."
);
}
protected async executeCore(args: ImportImageArgs): Promise<ToolResponse> {
// check that file path is absolute
FileUtils.checkPathIsAbsolute(args.filePath);
// check that file exists
if (!fs.existsSync(args.filePath)) {
throw new Error(`File not found: ${args.filePath}`);
}
// read the file as binary data
const fileData = fs.readFileSync(args.filePath);
const base64Data = fileData.toString("base64");
// determine mime type from file extension
const ext = path.extname(args.filePath).toLowerCase();
const mimeType = ImportImageTool.MIME_TYPES[ext];
if (!mimeType) {
const supportedExtensions = Object.keys(ImportImageTool.MIME_TYPES).join(", ");
throw new Error(
`Unsupported image format: ${ext}. Supported formats (file extensions): ${supportedExtensions}`
);
}
// generate and execute JavaScript code to import the image
const fileName = path.basename(args.filePath);
const escapedBase64 = base64Data.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const escapedFileName = fileName.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const code = `
const rectangle = await penpotUtils.importImage(
'${escapedBase64}', '${mimeType}', '${escapedFileName}',
${args.x ?? "undefined"}, ${args.y ?? "undefined"},
${args.width ?? "undefined"}, ${args.height ?? "undefined"});
return { shapeId: rectangle.id };
`;
const task = new ExecuteCodePluginTask({ code: code });
const executionResult = await this.mcpServer.pluginBridge.executePluginTask(task);
return new TextResponse(JSON.stringify(executionResult.data?.result, null, 2));
}
}

View File

@@ -0,0 +1,88 @@
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 { ApiDocs } from "../ApiDocs";
/**
* Arguments class for the PenpotApiInfoTool
*/
export class PenpotApiInfoArgs {
static schema = {
type: z.string().min(1, "Type name cannot be empty"),
member: z.string().optional(),
};
/**
* The API type name to retrieve information for.
*/
type!: string;
/**
* The specific member name to retrieve (optional).
*/
member?: string;
}
/**
* Tool for retrieving Penpot API documentation information.
*
* This tool provides access to API type documentation loaded from YAML files,
* allowing retrieval of either full type documentation or specific member details.
*/
export class PenpotApiInfoTool extends Tool<PenpotApiInfoArgs> {
private static readonly MAX_FULL_TEXT_CHARS = 2000;
private readonly apiDocs: ApiDocs;
/**
* Creates a new PenpotApiInfo tool instance.
*
* @param mcpServer - The MCP server instance
*/
constructor(mcpServer: PenpotMcpServer, apiDocs: ApiDocs) {
super(mcpServer, PenpotApiInfoArgs.schema);
this.apiDocs = apiDocs;
}
public getToolName(): string {
return "penpot_api_info";
}
public getToolDescription(): string {
return (
"Retrieves Penpot API documentation for types and their members." +
"Be sure to read the 'Penpot High-Level Overview' first."
);
}
protected async executeCore(args: PenpotApiInfoArgs): Promise<ToolResponse> {
const apiType = this.apiDocs.getType(args.type);
if (!apiType) {
throw new Error(`API type "${args.type}" not found`);
}
if (args.member) {
// return specific member documentation
const memberDoc = apiType.getMember(args.member);
if (!memberDoc) {
throw new Error(`Member "${args.member}" not found in type "${args.type}"`);
}
return new TextResponse(memberDoc);
} else {
// return full text or overview based on length
const fullText = apiType.getFullText();
if (fullText.length <= PenpotApiInfoTool.MAX_FULL_TEXT_CHARS) {
return new TextResponse(fullText);
} else {
return new TextResponse(
apiType.getOverviewText() +
"\n\nMember details not provided (too long). " +
"Call this tool with a member name for more information."
);
}
}
}
}

View File

@@ -0,0 +1,44 @@
import * as path from "path";
import * as fs from "fs";
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 (!path.isAbsolute(filePath)) {
throw new Error(`The specified file path must be absolute: ${filePath}`);
}
}
public static createParentDirectories(filePath: string): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
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);
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);
fs.writeFileSync(filePath, text, { encoding: "utf-8" });
}
}

22
mcp/server/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"resolveJsonModule": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

2
mcp/types-generator/.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# SCM syntax highlighting
pixi.lock linguist-language=YAML linguist-generated=true

4
mcp/types-generator/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
# pixi environments
.pixi
*.egg-info

View File

@@ -0,0 +1,27 @@
# Types Generator
This subproject contains helper scripts used in the development of the
Penpot MCP server for generate the types yaml.
## Setup
This project uses [pixi](https://pixi.sh) for environment management
(already included in devenv).
Install the environment via
pixi install
## Scripts
### Preparation of API Documentation for the MCP Server
The script `prepare_api_docs.py` reads API documentation from the Web
and collects it in a single yaml file, which is then used by an MCP
tool to provide API documentation to an LLM on demand.
Running the script:
pixi run python prepare_api_docs.py
This will generate `../mcp-server/data/api_types.yml`.

1090
mcp/types-generator/pixi.lock generated Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
[workspace]
authors = ["Oraios AI <info@oraios-ai.de>"]
channels = ["conda-forge"]
description = "Scripts supporting the development of the Penpot MCP server"
name = "penpot-mcp-scripts"
platforms = ["win-64","linux-64"]
version = "0.1.0"
[tasks]
[dependencies]
python = "3.11.*"
pixi-pycharm = ">=0.0.9,<0.0.10"
beautifulsoup4 = ">=4.13.5,<5"
markdownify = ">=1.1.0,<2"
requests = ">=2.32.5,<3"
"ruamel.yaml" = ">=0.18.15,<0.19"
[pypi-dependencies]
sensai-utils = ">=1.5.0, <2"

View File

@@ -0,0 +1,259 @@
import collections
import dataclasses
import os
from dataclasses import dataclass
from io import StringIO
from pathlib import Path
import requests
from bs4 import BeautifulSoup, Tag
from markdownify import MarkdownConverter
from ruamel.yaml import YAML
from ruamel.yaml.scalarstring import LiteralScalarString
from sensai.util import logging
log = logging.getLogger(__name__)
class PenpotAPIContentMarkdownConverter(MarkdownConverter):
"""
Markdown converter for Penpot API docs, specifically for the .col-content element
(and sub-elements thereof)
"""
def process_tag(self, node, parent_tags=None):
soup = BeautifulSoup(str(node), "html.parser")
# skip breadcrumbs
if "class" in node.attrs and "tsd-breadcrumb" in node.attrs["class"]:
return ""
# convert h5 and h4 to plain text
if node.name in ["h5", "h4"]:
return soup.get_text()
text = soup.get_text()
# convert tsd-tag code elements (containing e.g. "Readonly" and "Optional" designations)
# If we encounter them at this level, we just remove them, as they are redundant.
# The significant such tags are handled in the tsd-signature processing below.
if node.name == "code" and "class" in node.attrs and "tsd-tag" in node.attrs["class"]:
return ""
# skip buttons (e.g. "Copy")
if node.name == "button":
return ""
# skip links to definitions in <li> elements
if node.name == "li" and text.startswith("Defined in"):
return ""
# for links, just return the text
if node.name == "a":
return text
# skip inheritance information
if node.name == "p" and text.startswith("Inherited from"):
return ""
# remove index with links
if "class" in node.attrs and "tsd-index-content" in node.attrs["class"]:
return ""
# convert <pre> blocks to markdown code blocks
if node.name == "pre":
for button in soup.find_all("button"):
button.decompose()
return f"\n```\n{soup.get_text().strip()}\n```\n\n"
# convert tsd-signature elements to code blocks, converting <br> to newlines
if "class" in node.attrs and "tsd-signature" in node.attrs["class"]:
# convert <br> to newlines
for br in soup.find_all("br"):
br.replace_with("\n")
# process tsd-tags (keeping only "readonly"; optional is redundant, as it is indicated via "?")
for tag in soup.find_all(attrs={"class": "tsd-tag"}):
tag_lower = tag.get_text().strip().lower()
if tag_lower in ["readonly"]:
tag.replace_with(f"{tag_lower} ")
else:
tag.decompose()
# return as code block
return f"\n```\n{soup.get_text()}\n```\n\n"
# other cases: use the default processing
return super().process_tag(node, parent_tags=parent_tags)
@dataclass
class TypeInfo:
overview: str
"""
the main type information, which contains all the declarations/signatures but no descriptions
"""
members: dict[str, dict[str, str]]
"""
mapping from member type (e.g. "Properties", "Methods") to a mapping of member name to markdown description
"""
def add_referencing_types(self, referencing_types: set[str]):
if referencing_types:
self.overview += "\n\nReferenced by: " + ", ".join(sorted(referencing_types))
class YamlConverter:
"""Converts dictionaries to YAML with all strings in block literal style"""
def __init__(self):
self.yaml = YAML()
self.yaml.preserve_quotes = True
self.yaml.width = 4096 # Prevent line wrapping
def _convert_strings_to_block(self, obj):
if isinstance(obj, dict):
return {k: self._convert_strings_to_block(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [self._convert_strings_to_block(item) for item in obj]
elif isinstance(obj, str):
return LiteralScalarString(obj)
else:
return obj
def to_yaml(self, data):
processed_data = self._convert_strings_to_block(data)
stream = StringIO()
self.yaml.dump(processed_data, stream)
return stream.getvalue()
def to_file(self, data, filepath):
processed_data = self._convert_strings_to_block(data)
with open(filepath, 'w', encoding='utf-8') as f:
self.yaml.dump(processed_data, f)
class PenpotAPIDocsProcessor:
def __init__(self):
self.md_converter = PenpotAPIContentMarkdownConverter()
self.base_url = "https://penpot-plugins-api-doc.pages.dev"
self.types: dict[str, TypeInfo] = {}
self.type_referenced_by: dict[str, set[str]] = collections.defaultdict(set)
def run(self, target_dir: str):
os.makedirs(target_dir, exist_ok=True)
# find links to all interfaces and types
modules_page = self._fetch("modules")
soup = BeautifulSoup(modules_page, "html.parser")
content = soup.find(attrs={"class": "col-content"})
links = content.find_all("a", href=True)
# process each link, converting interface and type pages to markdown
for link in links:
href = link['href']
if href.startswith("interfaces/") or href.startswith("types/"):
type_name = href.split("/")[-1].replace(".html", "")
log.info("Processing page: %s", type_name)
type_info = self.process_page(href, type_name)
self.types[type_name] = type_info
# add type reference information
for type_name, type_info in self.types.items():
referencing_types = self.type_referenced_by.get(type_name, set())
type_info.add_referencing_types(referencing_types)
# save to yaml
yaml_path = os.path.join(target_dir, "api_types.yml")
log.info("Writing API type information to %s", yaml_path)
data_dict = {k: dataclasses.asdict(v) for k, v in self.types.items()}
YamlConverter().to_file(data_dict, yaml_path)
def _fetch(self, rel_url: str) -> str:
response = requests.get(f"{self.base_url}/{rel_url}")
if response.status_code != 200:
raise Exception(f"Failed to retrieve page: {response.status_code}")
html_content = response.text
return html_content
def _html_to_markdown(self, html_content: str) -> str:
md = self.md_converter.convert(html_content)
md = md.replace("\xa0", " ") # replace non-breaking spaces
return md.strip()
def process_page(self, rel_url: str, type_name: str) -> TypeInfo:
html_content = self._fetch(rel_url)
soup = BeautifulSoup(html_content, "html.parser")
content = soup.find(attrs={"class": "col-content"})
# full_text = self._html_to_markdown(str(content))
# extract individual members
members = {}
member_group_tags = []
for el in content.children:
if isinstance(el, Tag):
if "class" in el.attrs and "tsd-member-group" in el.attrs["class"]:
member_group_tags.append(el)
members_type = el.find("h2").get_text().strip()
members_in_group = {}
members[members_type] = members_in_group
for member_tag in el.find_all(attrs={"class": "tsd-member"}):
member_anchor = member_tag.find("a", attrs={"class": "tsd-anchor"}, recursive=False)
member_name = member_anchor.attrs["id"]
member_heading = member_tag.find("h3")
# extract tsd-tag info (e.g., "Readonly") from the heading and reinsert it into the signature,
# where we want to see it. The heading is removed, as it is redundant.
if member_heading:
tags_in_heading = member_heading.find_all(attrs={"class": "tsd-tag"})
if tags_in_heading:
signature_tag = member_tag.find(attrs={"class": "tsd-signature"})
if signature_tag:
for tag in reversed(tags_in_heading):
signature_tag.insert(0, tag)
member_heading.decompose()
# convert to markdown
tag_text = str(member_tag)
members_in_group[member_name] = self._html_to_markdown(tag_text)
# record references to other types in signature
signature = content.find("div", attrs={"class": "tsd-signature"})
for link_to_type in signature.find_all("a", attrs={"class": "tsd-signature-type"}):
referenced_type_name = link_to_type.get_text().strip()
self.type_referenced_by[referenced_type_name].add(type_name)
# remove the member groups from the soup
for tag in member_group_tags:
tag.decompose()
# overview is what remains in content after removing member groups
overview = self._html_to_markdown(str(content))
return TypeInfo(
overview=overview,
members=members
)
def main():
target_dir = Path(__file__).parent.parent / "server" / "data"
PenpotAPIDocsProcessor().run(target_dir=str(target_dir))
def debug_type_conversion(rel_url: str):
"""
This function is for debugging purposes only.
It processes a single type page and prints the converted markdown to the console.
:param rel_url: relative URL of the type page (e.g., "interfaces/ShapeBase")
"""
type_name = rel_url.split("/")[-1]
processor = PenpotAPIDocsProcessor()
type_info = processor.process_page(rel_url, type_name)
print(f"--- overview ---\n{type_info.overview}\n")
for member_type, members in type_info.members.items():
print(f"\n{member_type}:")
for member_name, member_md in members.items():
print(f"--- {member_name} ---\n{member_md}\n")
if __name__ == '__main__':
# debug_type_conversion("interfaces/ShapeBase")
logging.run_main(main)

View File

@@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC",
"private": true,
"packageManager": "pnpm@10.26.2+sha512.0e308ff2005fc7410366f154f625f6631ab2b16b1d2e70238444dd6ae9d630a8482d92a451144debc492416896ed16f7b114a86ec68b8404b2443869e68ffda6",
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"