mirror of
https://github.com/penpot/penpot.git
synced 2026-01-29 08:41:55 -05:00
Compare commits
8 Commits
tokens-api
...
niwinz-mcp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a59bf0936a | ||
|
|
9b3bfabd0f | ||
|
|
b733406cb3 | ||
|
|
292fd57daf | ||
|
|
ade3924222 | ||
|
|
6dbe960dbc | ||
|
|
d8775be937 | ||
|
|
2b019015c0 |
@@ -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 \
|
||||
|
||||
58
docker/images/Dockerfile.mcp
Normal file
58
docker/images/Dockerfile.mcp
Normal 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"]
|
||||
40
manage.sh
40
manage.sh
@@ -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
11
mcp/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
.idea
|
||||
node_modules
|
||||
dist
|
||||
*.bak
|
||||
*.orig
|
||||
temp
|
||||
*.tsbuildinfo
|
||||
|
||||
# Log files
|
||||
logs/
|
||||
*.log
|
||||
7
mcp/.prettierignore
Normal file
7
mcp/.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
||||
*.md
|
||||
*.json
|
||||
python-scripts/
|
||||
.serena/
|
||||
|
||||
# auto-generated files
|
||||
mcp-server/data/api_types.yml
|
||||
20
mcp/.prettierrc
Normal file
20
mcp/.prettierrc
Normal 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
1
mcp/.serena/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/cache
|
||||
25
mcp/.serena/memories/code_style_conventions.md
Normal file
25
mcp/.serena/memories/code_style_conventions.md
Normal 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)
|
||||
|
||||
91
mcp/.serena/memories/project_overview.md
Normal file
91
mcp/.serena/memories/project_overview.md
Normal 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
|
||||
```
|
||||
|
||||
70
mcp/.serena/memories/suggested_commands.md
Normal file
70
mcp/.serena/memories/suggested_commands.md
Normal 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
|
||||
```
|
||||
56
mcp/.serena/memories/task_completion_guidelines.md
Normal file
56
mcp/.serena/memories/task_completion_guidelines.md
Normal 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
130
mcp/.serena/project.yml
Normal 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
239
mcp/README.md
Normal file
@@ -0,0 +1,239 @@
|
||||

|
||||
|
||||
# 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.
|
||||
|
||||
|
||||
[](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.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
[](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
18
mcp/common/package.json
Normal 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
1
mcp/common/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./types";
|
||||
85
mcp/common/src/types.ts
Normal file
85
mcp/common/src/types.ts
Normal 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
19
mcp/common/tsconfig.json
Normal 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"]
|
||||
}
|
||||
41
mcp/docs/multi-user-mode.md
Normal file
41
mcp/docs/multi-user-mode.md
Normal 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
25
mcp/package.json
Normal 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
24
mcp/plugin/.gitignore
vendored
Normal 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
21
mcp/plugin/README.md
Normal 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
15
mcp/plugin/index.html
Normal 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
22
mcp/plugin/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
mcp/plugin/public/manifest.json
Normal file
6
mcp/plugin/public/manifest.json
Normal 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"]
|
||||
}
|
||||
394
mcp/plugin/src/PenpotUtils.ts
Normal file
394
mcp/plugin/src/PenpotUtils.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
77
mcp/plugin/src/TaskHandler.ts
Normal file
77
mcp/plugin/src/TaskHandler.ts
Normal 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
110
mcp/plugin/src/main.ts
Normal 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
69
mcp/plugin/src/plugin.ts
Normal 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
10
mcp/plugin/src/style.css
Normal file
@@ -0,0 +1,10 @@
|
||||
@import "@penpot/plugin-styles/styles.css";
|
||||
|
||||
body {
|
||||
line-height: 1.5;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-block-end: 0.75rem;
|
||||
}
|
||||
212
mcp/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts
Normal file
212
mcp/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts
Normal 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
4
mcp/plugin/src/vite-env.d.ts
vendored
Normal 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
24
mcp/plugin/tsconfig.json
Normal 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
46
mcp/plugin/vite.config.ts
Normal 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
2665
mcp/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
mcp/pnpm-workspace.yaml
Normal file
6
mcp/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
#shamefullyHoist: true
|
||||
|
||||
packages:
|
||||
- "./common"
|
||||
- "./server"
|
||||
- "./plugin"
|
||||
BIN
mcp/resources/architecture.png
Normal file
BIN
mcp/resources/architecture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
38
mcp/scripts/build
Executable file
38
mcp/scripts/build
Executable 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
0
mcp/server/.gitignore
vendored
Normal file
24
mcp/server/README.md
Normal file
24
mcp/server/README.md
Normal 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
18268
mcp/server/data/api_types.yml
Normal file
File diff suppressed because it is too large
Load Diff
253
mcp/server/data/prompts.yml
Normal file
253
mcp/server/data/prompts.yml
Normal 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
52
mcp/server/package.json
Normal 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
2840
mcp/server/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
128
mcp/server/src/ApiDocs.ts
Normal file
128
mcp/server/src/ApiDocs.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
86
mcp/server/src/ConfigurationLoader.ts
Normal file
86
mcp/server/src/ConfigurationLoader.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
262
mcp/server/src/PenpotMcpServer.ts
Normal file
262
mcp/server/src/PenpotMcpServer.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
227
mcp/server/src/PluginBridge.ts
Normal file
227
mcp/server/src/PluginBridge.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
122
mcp/server/src/PluginTask.ts
Normal file
122
mcp/server/src/PluginTask.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
112
mcp/server/src/ReplServer.ts
Normal file
112
mcp/server/src/ReplServer.ts
Normal 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 <pre> 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
121
mcp/server/src/Tool.ts
Normal 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>;
|
||||
}
|
||||
97
mcp/server/src/ToolResponse.ts
Normal file
97
mcp/server/src/ToolResponse.ts
Normal 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: protocol’s 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
67
mcp/server/src/index.ts
Normal 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
80
mcp/server/src/logger.ts
Normal 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 });
|
||||
}
|
||||
554
mcp/server/src/static/repl.html
Normal file
554
mcp/server/src/static/repl.html
Normal 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>
|
||||
22
mcp/server/src/tasks/ExecuteCodePluginTask.ts
Normal file
22
mcp/server/src/tasks/ExecuteCodePluginTask.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
77
mcp/server/src/tools/ExecuteCodeTool.ts
Normal file
77
mcp/server/src/tools/ExecuteCodeTool.ts
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
147
mcp/server/src/tools/ExportShapeTool.ts
Normal file
147
mcp/server/src/tools/ExportShapeTool.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
26
mcp/server/src/tools/HighLevelOverviewTool.ts
Normal file
26
mcp/server/src/tools/HighLevelOverviewTool.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
123
mcp/server/src/tools/ImportImageTool.ts
Normal file
123
mcp/server/src/tools/ImportImageTool.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
88
mcp/server/src/tools/PenpotApiInfoTool.ts
Normal file
88
mcp/server/src/tools/PenpotApiInfoTool.ts
Normal 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
44
mcp/server/src/utils/FileUtils.ts
Normal file
44
mcp/server/src/utils/FileUtils.ts
Normal 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
22
mcp/server/tsconfig.json
Normal 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
2
mcp/types-generator/.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# SCM syntax highlighting
|
||||
pixi.lock linguist-language=YAML linguist-generated=true
|
||||
4
mcp/types-generator/.gitignore
vendored
Normal file
4
mcp/types-generator/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
# pixi environments
|
||||
.pixi
|
||||
*.egg-info
|
||||
27
mcp/types-generator/README.md
Normal file
27
mcp/types-generator/README.md
Normal 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
1090
mcp/types-generator/pixi.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
mcp/types-generator/pixi.toml
Normal file
20
mcp/types-generator/pixi.toml
Normal 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"
|
||||
259
mcp/types-generator/prepare_api_docs.py
Normal file
259
mcp/types-generator/prepare_api_docs.py
Normal 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)
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user