mirror of
https://github.com/penpot/penpot.git
synced 2026-02-04 11:42:15 -05:00
Compare commits
28 Commits
juanfran-d
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44c7d3fbd6 | ||
|
|
3d50aa6cb2 | ||
|
|
06afd94a74 | ||
|
|
e7d9dca55e | ||
|
|
c14ccc18b8 | ||
|
|
7d09d930fe | ||
|
|
0d9b7ca696 | ||
|
|
d215a5c402 | ||
|
|
f65292a13c | ||
|
|
94722fdec2 | ||
|
|
28509e0418 | ||
|
|
9569fa2bcb | ||
|
|
852b31c3a0 | ||
|
|
84b3f5d7c6 | ||
|
|
76bd31fe7d | ||
|
|
77bbf30ae4 | ||
|
|
693b52bf45 | ||
|
|
0f51b23ce7 | ||
|
|
ec61aa6b6d | ||
|
|
abc1773f65 | ||
|
|
93f5e74bb0 | ||
|
|
38179ba11e | ||
|
|
719a95246a | ||
|
|
e590cd852d | ||
|
|
a9741073e5 | ||
|
|
599656c31e | ||
|
|
16f22a7b5c | ||
|
|
a1460115e8 |
38
.github/ISSUE_TEMPLATE/new-render-bug-report.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/new-render-bug-report.md
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: New Render Bug Report
|
||||
about: Create a report about the bugs you have found in the new render
|
||||
title: ''
|
||||
labels: new render
|
||||
assignees: claragvinola
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Steps to Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots or screen recordings**
|
||||
If applicable, add screenshots or screen recording to help illustrate your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
2
.github/workflows/build-bundle.yml
vendored
2
.github/workflows/build-bundle.yml
vendored
@@ -40,7 +40,7 @@ on:
|
||||
jobs:
|
||||
build-bundle:
|
||||
name: Build and Upload Penpot Bundle
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-01
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
|
||||
7
.github/workflows/build-docker-devenv.yml
vendored
7
.github/workflows/build-docker-devenv.yml
vendored
@@ -7,9 +7,14 @@ jobs:
|
||||
build-and-push:
|
||||
name: Build and push DevEnv Docker image
|
||||
environment: release-admins
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
|
||||
steps:
|
||||
- name: Set common environment variables
|
||||
run: |
|
||||
# Each job execution will use its own docker configuration.
|
||||
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
|
||||
16
.github/workflows/build-docker.yml
vendored
16
.github/workflows/build-docker.yml
vendored
@@ -19,9 +19,14 @@ on:
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push Penpot Docker Images
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: penpot-runner-02
|
||||
|
||||
steps:
|
||||
- name: Set common environment variables
|
||||
run: |
|
||||
# Each job execution will use its own docker configuration.
|
||||
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -66,6 +71,15 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
# To avoid the “429 Too Many Requests” error when downloading
|
||||
# images from DockerHub for unregistered users.
|
||||
# https://docs.docker.com/docker-hub/usage/
|
||||
- name: Login to DockerHub Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
||||
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
|
||||
2
.github/workflows/commit-checker.yml
vendored
2
.github/workflows/commit-checker.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Check Commit Type
|
||||
uses: gsactions/commit-message-checker@v2
|
||||
with:
|
||||
pattern: '^(((:(lipstick|globe_with_meridians|wrench|books|arrow_up|arrow_down|zap|ambulance|construction|boom|fire|whale|bug|sparkles|paperclip|tada|recycle|rewind|construction_worker):)\s[A-Z].*[^.])|(Merge|Revert).+[^.])$'
|
||||
pattern: '^(((:(lipstick|globe_with_meridians|wrench|books|arrow_up|arrow_down|zap|ambulance|construction|boom|fire|whale|bug|sparkles|paperclip|tada|recycle|rewind|construction_worker):)\s[A-Z].*[^.])|(Merge|Revert|Reapply).+[^.])$'
|
||||
flags: 'gm'
|
||||
error: 'Commit should match CONTRIBUTING.md guideline'
|
||||
checkAllCommitMessages: 'true' # optional: this checks all commits associated with a pull request
|
||||
|
||||
45
.github/workflows/tests-mcp.yml
vendored
Normal file
45
.github/workflows/tests-mcp.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
name: "MCP CI"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
- main
|
||||
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
|
||||
paths:
|
||||
- 'mcp/**'
|
||||
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
- main
|
||||
|
||||
paths:
|
||||
- 'mcp/**'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: "Test"
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup
|
||||
working-directory: ./mcp
|
||||
run: ./scripts/setup
|
||||
|
||||
- name: Check
|
||||
working-directory: ./mcp
|
||||
run: |
|
||||
pnpm run fmt:check;
|
||||
pnpm -r run build;
|
||||
pnpm -r run types:check;
|
||||
@@ -1,10 +1,6 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 2.13.0 (Unreleased)
|
||||
|
||||
### :boom: Breaking changes & Deprecations
|
||||
|
||||
### :rocket: Epics and highlights
|
||||
## 2.13.0
|
||||
|
||||
### :heart: Community contributions (Thank you!)
|
||||
|
||||
@@ -38,6 +34,9 @@
|
||||
- Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113)
|
||||
- Fix several race conditions on path editor [Github #8187](https://github.com/penpot/penpot/pull/8187)
|
||||
- Fix app freeze when introducing an error on a very long token name [Taiga #13214](https://tree.taiga.io/project/penpot/issue/13214)
|
||||
- Fix import a file with shadow tokens [Taiga #13229](https://tree.taiga.io/project/penpot/issue/13229)
|
||||
- Fix allow spaces on token description [Taiga #13184](https://tree.taiga.io/project/penpot/issue/13184)
|
||||
- Fix error when creating a token with an invalid name [Taiga #13219](https://tree.taiga.io/project/penpot/issue/13219)
|
||||
|
||||
## 2.12.1
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
:deps
|
||||
{penpot/common {:local/root "../common"}
|
||||
org.clojure/clojure {:mvn/version "1.12.2"}
|
||||
org.clojure/clojure {:mvn/version "1.12.4"}
|
||||
org.clojure/tools.namespace {:mvn/version "1.5.0"}
|
||||
|
||||
com.github.luben/zstd-jni {:mvn/version "1.5.7-4"}
|
||||
@@ -28,8 +28,8 @@
|
||||
com.google.guava/guava {:mvn/version "33.4.8-jre"}
|
||||
|
||||
funcool/yetti
|
||||
{:git/tag "v11.8"
|
||||
:git/sha "1d1b33f"
|
||||
{:git/tag "v11.9"
|
||||
:git/sha "5fad7a9"
|
||||
:git/url "https://github.com/funcool/yetti.git"
|
||||
:exclusions [org.slf4j/slf4j-api]}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
metosin/reitit-core {:mvn/version "0.9.1"}
|
||||
nrepl/nrepl {:mvn/version "1.4.0"}
|
||||
|
||||
org.postgresql/postgresql {:mvn/version "42.7.7"}
|
||||
org.postgresql/postgresql {:mvn/version "42.7.9"}
|
||||
org.xerial/sqlite-jdbc {:mvn/version "3.50.3.0"}
|
||||
|
||||
com.zaxxer/HikariCP {:mvn/version "7.0.2"}
|
||||
@@ -49,7 +49,7 @@
|
||||
buddy/buddy-hashers {:mvn/version "2.0.167"}
|
||||
buddy/buddy-sign {:mvn/version "3.6.1-359"}
|
||||
|
||||
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.2"}
|
||||
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.3"}
|
||||
|
||||
org.jsoup/jsoup {:mvn/version "1.21.2"}
|
||||
org.im4java/im4java
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
;; Pretty Print specs
|
||||
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.33.10"}}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.41.21"}}
|
||||
|
||||
:paths ["src" "resources" "target/classes"]
|
||||
:aliases
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{:deps
|
||||
{org.clojure/clojure {:mvn/version "1.12.2"}
|
||||
{org.clojure/clojure {:mvn/version "1.12.4"}
|
||||
org.clojure/data.json {:mvn/version "2.5.1"}
|
||||
org.clojure/tools.cli {:mvn/version "1.1.230"}
|
||||
org.clojure/test.check {:mvn/version "1.1.1"}
|
||||
@@ -9,15 +9,15 @@
|
||||
org.apache.commons/commons-pool2 {:mvn/version "2.12.1"}
|
||||
|
||||
;; Logging
|
||||
org.apache.logging.log4j/log4j-api {:mvn/version "2.25.1"}
|
||||
org.apache.logging.log4j/log4j-core {:mvn/version "2.25.1"}
|
||||
org.apache.logging.log4j/log4j-web {:mvn/version "2.25.1"}
|
||||
org.apache.logging.log4j/log4j-jul {:mvn/version "2.25.1"}
|
||||
org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.25.1"}
|
||||
org.apache.logging.log4j/log4j-api {:mvn/version "2.25.3"}
|
||||
org.apache.logging.log4j/log4j-core {:mvn/version "2.25.3"}
|
||||
org.apache.logging.log4j/log4j-web {:mvn/version "2.25.3"}
|
||||
org.apache.logging.log4j/log4j-jul {:mvn/version "2.25.3"}
|
||||
org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.25.3"}
|
||||
org.slf4j/slf4j-api {:mvn/version "2.0.17"}
|
||||
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.40"}
|
||||
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.41"}
|
||||
|
||||
selmer/selmer {:mvn/version "1.12.69"}
|
||||
selmer/selmer {:mvn/version "1.12.70"}
|
||||
criterium/criterium {:mvn/version "0.4.6"}
|
||||
|
||||
metosin/jsonista {:mvn/version "0.3.13"}
|
||||
@@ -27,7 +27,7 @@
|
||||
com.cognitect/transit-clj {:mvn/version "1.0.333"}
|
||||
com.cognitect/transit-cljs {:mvn/version "0.8.280"}
|
||||
java-http-clj/java-http-clj {:mvn/version "0.4.3"}
|
||||
integrant/integrant {:mvn/version "1.0.0"}
|
||||
integrant/integrant {:mvn/version "1.0.1"}
|
||||
|
||||
funcool/cuerdas {:mvn/version "2026.415"}
|
||||
funcool/promesa
|
||||
|
||||
@@ -97,9 +97,12 @@
|
||||
(def token-types
|
||||
(into #{} (keys token-type->dtcg-token-type)))
|
||||
|
||||
(def token-name-validation-regex
|
||||
#"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$")
|
||||
|
||||
(def token-name-ref
|
||||
[:re {:title "TokenNameRef" :gen/gen sg/text}
|
||||
#"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$"])
|
||||
token-name-validation-regex])
|
||||
|
||||
(def ^:private schema:color
|
||||
[:map
|
||||
|
||||
@@ -1462,11 +1462,12 @@ Will return a value that matches this schema:
|
||||
(def ^:private schema:dtcg-node
|
||||
[:schema {:registry
|
||||
{::simple-value
|
||||
[:or :string :int :double]
|
||||
[:or :string :int :double ::sm/boolean]
|
||||
::value
|
||||
[:or
|
||||
[:ref ::simple-value]
|
||||
[:vector ::simple-value]
|
||||
[:vector [:map-of :string ::simple-value]]
|
||||
[:map-of :string [:or
|
||||
[:ref ::simple-value]
|
||||
[:vector ::simple-value]]]]}}
|
||||
|
||||
@@ -31,7 +31,7 @@ RUN set -ex; \
|
||||
|
||||
FROM base AS setup-node
|
||||
|
||||
ENV NODE_VERSION=v22.21.1 \
|
||||
ENV NODE_VERSION=v22.22.0 \
|
||||
PATH=/opt/node/bin:$PATH
|
||||
|
||||
RUN set -eux; \
|
||||
@@ -97,18 +97,19 @@ RUN set -eux; \
|
||||
|
||||
FROM base AS setup-jvm
|
||||
|
||||
ENV CLOJURE_VERSION=1.12.3.1577
|
||||
# https://clojure.org/releases/tools
|
||||
ENV CLOJURE_VERSION=1.12.4.1602
|
||||
|
||||
RUN set -eux; \
|
||||
ARCH="$(dpkg --print-architecture)"; \
|
||||
case "${ARCH}" in \
|
||||
aarch64|arm64) \
|
||||
ESUM='8c5321f16d9f1d8149f83e4e9ff8ca5d9e94320b92d205e6db42a604de3d1140'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_aarch64.tar.gz'; \
|
||||
ESUM='9903c6b19183a33725ca1dfdae5b72400c9d00995c76fafc4a0d31c5152f33f7'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_aarch64.tar.gz'; \
|
||||
;; \
|
||||
amd64|x86_64) \
|
||||
ESUM='471b3e62bdffaed27e37005d842d8639f10d244ccce1c7cdebf7abce06c8313e'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_x64.tar.gz'; \
|
||||
ESUM='946ad9766d98fc6ab495a1a120072197db54997f6925fb96680f1ecd5591db4e'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_x64.tar.gz'; \
|
||||
;; \
|
||||
*) \
|
||||
echo "Unsupported arch: ${ARCH}"; \
|
||||
@@ -179,9 +180,10 @@ RUN set -eux; \
|
||||
|
||||
FROM base AS setup-utils
|
||||
|
||||
ENV CLJKONDO_VERSION=2025.07.28 \
|
||||
ENV CLJKONDO_VERSION=2026.01.19 \
|
||||
BABASHKA_VERSION=1.12.208 \
|
||||
CLJFMT_VERSION=0.13.1
|
||||
CLJFMT_VERSION=0.15.6 \
|
||||
PIXI_VERSION=0.63.2
|
||||
|
||||
RUN set -ex; \
|
||||
ARCH="$(dpkg --print-architecture)"; \
|
||||
@@ -224,6 +226,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 \
|
||||
@@ -375,7 +397,7 @@ ENV LANG='C.UTF-8' \
|
||||
RUSTUP_HOME="/opt/rustup" \
|
||||
PATH="/opt/jdk/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH"
|
||||
|
||||
COPY --from=penpotapp/imagemagick:7.1.2-0 /opt/imagick /opt/imagick
|
||||
COPY --from=penpotapp/imagemagick:7.1.2-13 /opt/imagick /opt/imagick
|
||||
COPY --from=setup-jvm /opt/jdk /opt/jdk
|
||||
COPY --from=setup-jvm /opt/clojure /opt/clojure
|
||||
COPY --from=setup-node /opt/node /opt/node
|
||||
@@ -398,7 +420,6 @@ COPY files/Caddyfile /home/
|
||||
COPY files/selfsigned.crt /home/
|
||||
COPY files/selfsigned.key /home/
|
||||
COPY files/start-tmux.sh /home/start-tmux.sh
|
||||
COPY files/start-tmux-back.sh /home/start-tmux-back.sh
|
||||
COPY files/entrypoint.sh /home/entrypoint.sh
|
||||
COPY files/init.sh /home/init.sh
|
||||
|
||||
|
||||
@@ -46,6 +46,11 @@ services:
|
||||
- 9090:9090
|
||||
- 9091:9091
|
||||
|
||||
# MCP
|
||||
- 4400:4400
|
||||
- 4401:4401
|
||||
- 4402:4402
|
||||
|
||||
environment:
|
||||
- EXTERNAL_UID=${CURRENT_USER_ID}
|
||||
# SMTP setup
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
{
|
||||
auto_https off
|
||||
auto_https off
|
||||
}
|
||||
|
||||
localhost:3449 {
|
||||
reverse_proxy localhost:4449
|
||||
tls /home/selfsigned.crt /home/selfsigned.key
|
||||
reverse_proxy localhost:4449
|
||||
tls /home/selfsigned.crt /home/selfsigned.key
|
||||
header -Strict-Transport-Security
|
||||
}
|
||||
|
||||
http://localhost:3450 {
|
||||
reverse_proxy localhost:4449
|
||||
reverse_proxy localhost:4449
|
||||
}
|
||||
|
||||
http://penpot-devenv-main:3450 {
|
||||
reverse_proxy localhost:4449
|
||||
reverse_proxy localhost:4449
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ export PATH="/home/penpot/.cargo/bin:/opt/jdk/bin:/opt/utils/bin:/opt/clojure/bi
|
||||
export CARGO_HOME="/home/penpot/.cargo"
|
||||
|
||||
alias l='ls --color -GFlh'
|
||||
alias rm='rm -r'
|
||||
alias ll='ls --color -GFlh'
|
||||
alias rm='rm -rf'
|
||||
alias ls='ls --color -F'
|
||||
alias lsd='ls -d *(/)'
|
||||
alias lsf='ls -h *(.)'
|
||||
|
||||
@@ -121,6 +121,28 @@ http {
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
location /mcp {
|
||||
alias /home/penpot/penpot/mcp/packages/plugin/dist;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
location /mcp/ws {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_pass http://127.0.0.1:4402;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
location /mcp/stream {
|
||||
proxy_pass http://127.0.0.1:4401/mcp;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
location /mcp/sse {
|
||||
proxy_pass http://127.0.0.1:4401/sse;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
location /admin {
|
||||
proxy_pass http://127.0.0.1:6063/admin;
|
||||
}
|
||||
@@ -141,8 +163,14 @@ http {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
}
|
||||
|
||||
location /nitrate/ {
|
||||
proxy_pass http://127.0.0.1:3000/;
|
||||
location /control-center {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /wasm-playground {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
set -g default-command "${SHELL}"
|
||||
set -g mouse off
|
||||
set -g history-limit 50000
|
||||
setw -g mode-keys emacs
|
||||
|
||||
@@ -6,7 +6,7 @@ ENV LANG='C.UTF-8' \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
TZ=Etc/UTC
|
||||
|
||||
ARG IMAGEMAGICK_VERSION=7.1.1-47
|
||||
ARG IMAGEMAGICK_VERSION=7.1.2-13
|
||||
|
||||
RUN set -e; \
|
||||
apt-get -qq update; \
|
||||
|
||||
@@ -5,7 +5,7 @@ ENV LANG='C.UTF-8' \
|
||||
LC_ALL='C.UTF-8' \
|
||||
JAVA_HOME="/opt/jdk" \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
NODE_VERSION=v22.21.1 \
|
||||
NODE_VERSION=v22.22.0 \
|
||||
TZ=Etc/UTC
|
||||
|
||||
RUN set -ex; \
|
||||
@@ -46,12 +46,12 @@ RUN set -eux; \
|
||||
ARCH="$(dpkg --print-architecture)"; \
|
||||
case "${ARCH}" in \
|
||||
aarch64|arm64) \
|
||||
ESUM='8c5321f16d9f1d8149f83e4e9ff8ca5d9e94320b92d205e6db42a604de3d1140'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_aarch64.tar.gz'; \
|
||||
ESUM='9903c6b19183a33725ca1dfdae5b72400c9d00995c76fafc4a0d31c5152f33f7'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_aarch64.tar.gz'; \
|
||||
;; \
|
||||
amd64|x86_64) \
|
||||
ESUM='471b3e62bdffaed27e37005d842d8639f10d244ccce1c7cdebf7abce06c8313e'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_x64.tar.gz'; \
|
||||
ESUM='946ad9766d98fc6ab495a1a120072197db54997f6925fb96680f1ecd5591db4e'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_x64.tar.gz'; \
|
||||
;; \
|
||||
*) \
|
||||
echo "Unsupported arch: ${ARCH}"; \
|
||||
|
||||
@@ -3,7 +3,7 @@ LABEL maintainer="Penpot <docker@penpot.app>"
|
||||
|
||||
ENV LANG=en_US.UTF-8 \
|
||||
LC_ALL=en_US.UTF-8 \
|
||||
NODE_VERSION=v22.21.1 \
|
||||
NODE_VERSION=v22.22.0 \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
PATH=/opt/node/bin:/opt/imagick/bin:$PATH
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ test("Update an already created text shape by prepending text", async ({
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Update an already created text shape by inserting text in between", async ({
|
||||
test.skip("Update an already created text shape by inserting text in between", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WorkspacePage(page, {
|
||||
@@ -151,7 +151,7 @@ test("Update a new text shape appending text by pasting text", async ({
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Update a new text shape prepending text by pasting text", async ({
|
||||
test.skip("Update a new text shape prepending text by pasting text", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
|
||||
@@ -36,10 +36,12 @@
|
||||
|
||||
(defn- hide-popover
|
||||
[node]
|
||||
(dom/unset-css-property! node "block-size")
|
||||
(dom/unset-css-property! node "inset-block-start")
|
||||
(dom/unset-css-property! node "inset-inline-start")
|
||||
(.hidePopover ^js node))
|
||||
(when (and (some? node)
|
||||
(fn? (.-hidePopover node)))
|
||||
(dom/unset-css-property! node "block-size")
|
||||
(dom/unset-css-property! node "inset-block-start")
|
||||
(dom/unset-css-property! node "inset-inline-start")
|
||||
(.hidePopover ^js node)))
|
||||
|
||||
(defn- calculate-placement-bounding-rect
|
||||
"Given a placement, calcultates the bounding rect for it taking in
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
(def context (mf/create-context nil))
|
||||
|
||||
(mf/defc form-input*
|
||||
[{:keys [name] :rest props}]
|
||||
[{:keys [name trim] :rest props}]
|
||||
|
||||
(let [form (mf/use-ctx context)
|
||||
input-name name
|
||||
@@ -33,7 +33,7 @@
|
||||
(mf/deps input-name)
|
||||
(fn [event]
|
||||
(let [value (-> event dom/get-target dom/get-input-value)]
|
||||
(fm/on-input-change form input-name value true))))
|
||||
(fm/on-input-change form input-name value trim))))
|
||||
|
||||
props
|
||||
(mf/spread-props props {:on-change on-change
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.types.color :as cl]
|
||||
[app.common.types.token :as cto]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.main.data.style-dictionary :as sd]
|
||||
[app.main.data.tinycolor :as tinycolor]
|
||||
@@ -51,12 +52,15 @@
|
||||
;; Both variants provide identical color-picker and text-input behavior, but
|
||||
;; differ in how they persist the value within the form’s nested structure.
|
||||
|
||||
|
||||
(defn- resolve-value
|
||||
[tokens prev-token token-name value]
|
||||
(let [token
|
||||
(let [valid-token-name?
|
||||
(and (string? token-name)
|
||||
(re-matches cto/token-name-validation-regex token-name))
|
||||
|
||||
token
|
||||
{:value value
|
||||
:name (if (str/blank? token-name)
|
||||
:name (if (or (not valid-token-name?) (str/blank? token-name))
|
||||
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
|
||||
token-name)}
|
||||
|
||||
|
||||
@@ -50,9 +50,13 @@
|
||||
|
||||
(defn- resolve-value
|
||||
[tokens prev-token token-name value]
|
||||
(let [token
|
||||
(let [valid-token-name?
|
||||
(and (string? token-name)
|
||||
(re-matches cto/token-name-validation-regex token-name))
|
||||
|
||||
token
|
||||
{:value (cto/split-font-family value)
|
||||
:name (if (str/blank? token-name)
|
||||
:name (if (or (not valid-token-name?) (str/blank? token-name))
|
||||
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
|
||||
token-name)}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.files.tokens :as cft]
|
||||
[app.common.types.token :as cto]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.main.data.style-dictionary :as sd]
|
||||
[app.main.data.workspace.tokens.format :as dwtf]
|
||||
@@ -140,9 +141,13 @@
|
||||
|
||||
(defn- resolve-value
|
||||
[tokens prev-token token-name value]
|
||||
(let [token
|
||||
(let [valid-token-name?
|
||||
(and (string? token-name)
|
||||
(re-matches cto/token-name-validation-regex token-name))
|
||||
|
||||
token
|
||||
{:value value
|
||||
:name (if (str/blank? token-name)
|
||||
:name (if (or (not valid-token-name?) (str/blank? token-name))
|
||||
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
|
||||
token-name)}
|
||||
tokens
|
||||
|
||||
@@ -230,6 +230,7 @@
|
||||
:placeholder (tr "workspace.tokens.enter-token-name" token-title)
|
||||
:max-length max-input-length
|
||||
:variant "comfortable"
|
||||
:trim true
|
||||
:auto-focus true}]
|
||||
|
||||
(when (and warning-name-change? (= action "edit"))
|
||||
|
||||
@@ -106,17 +106,20 @@
|
||||
|
||||
(defn stop-propagation
|
||||
[^js event]
|
||||
(when event
|
||||
(when (and (some? event)
|
||||
(fn? (.-stopPropagation event)))
|
||||
(.stopPropagation event)))
|
||||
|
||||
(defn stop-immediate-propagation
|
||||
[^js event]
|
||||
(when event
|
||||
(when (and (some? event)
|
||||
(fn? (.-stopImmediatePropagation event)))
|
||||
(.stopImmediatePropagation event)))
|
||||
|
||||
(defn prevent-default
|
||||
[^js event]
|
||||
(when event
|
||||
(when (and (some? event)
|
||||
(fn? (.-preventDefault event)))
|
||||
(.preventDefault event)))
|
||||
|
||||
(defn get-target
|
||||
|
||||
@@ -7,15 +7,16 @@
|
||||
(ns app.util.keyboard
|
||||
(:require
|
||||
[app.config :as cfg]
|
||||
[app.util.dom :as dom]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(defrecord KeyboardEvent [type key shift ctrl alt meta mod editing native-event]
|
||||
Object
|
||||
(preventDefault [_]
|
||||
(.preventDefault native-event))
|
||||
(dom/prevent-default native-event))
|
||||
|
||||
(stopPropagation [_]
|
||||
(.stopPropagation native-event)))
|
||||
(dom/stop-propagation native-event)))
|
||||
|
||||
(defn keyboard-event?
|
||||
[o]
|
||||
|
||||
@@ -7,7 +7,7 @@ export DEVENV_PNAME="penpotdev";
|
||||
export CURRENT_USER_ID=$(id -u);
|
||||
export CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD);
|
||||
|
||||
export IMAGEMAGICK_VERSION=7.1.2-0
|
||||
export IMAGEMAGICK_VERSION=7.1.2-13
|
||||
|
||||
# Safe directory to avoid ownership errors with Git
|
||||
git config --global --add safe.directory /home/penpot/penpot || true
|
||||
|
||||
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
|
||||
291
mcp/README.md
Normal file
291
mcp/README.md
Normal file
@@ -0,0 +1,291 @@
|
||||

|
||||
|
||||
# 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.x
|
||||
with corepack).
|
||||
|
||||
Following the installation of Node.js, the tools `pnpm` and `npx`
|
||||
should be available in your terminal. For ensure corepack installed
|
||||
and enabled correctly, just execute the `./scripts/setup`.
|
||||
|
||||
It is also required to have `caddy` executeable in the path, it is
|
||||
used for start a local server for generate types documentation from
|
||||
the current branch. If you want to run it outside devenv where all
|
||||
dependencies are already provided, please download caddy from
|
||||
[here](https://caddyserver.com/download).
|
||||
|
||||
You should probably be using penpot devenv, where all this
|
||||
dependencies are already present and correctly setup. But nothing
|
||||
prevents you execute this outside of devenv if you satisfy the
|
||||
specified dependencies.
|
||||
|
||||
|
||||
### 1. Build & Launch the MCP Server and the Plugin Server
|
||||
|
||||
If it's your first execution, install the required dependencies:
|
||||
|
||||
```shell
|
||||
cd mcp/
|
||||
./scripts/setup
|
||||
```
|
||||
|
||||
Then build all components and start the two servers:
|
||||
|
||||
```shell
|
||||
pnpm run bootstrap
|
||||
```
|
||||
|
||||
This bootstrap command will:
|
||||
|
||||
* install dependencies for all components (`pnpm -r run install`)
|
||||
* build all components (`pnpm -r run build`)
|
||||
* start all components (`pnpm -r --parallel run start`)
|
||||
|
||||
If you want to have types scrapped from a remote repository, the best
|
||||
approach is executing the following:
|
||||
|
||||
```shell
|
||||
PENPOT_PLUGINS_API_DOC_URL=https://doc.plugins.penpot.app pnpm run build:types
|
||||
pnpm run bootstrap
|
||||
```
|
||||
|
||||
Or this, if you want skip build step bacause you have already have all
|
||||
build artifacts ready (per example from previous `bootstrap` command):
|
||||
|
||||
```
|
||||
PENPOT_PLUGINS_API_DOC_URL=https://doc.plugins.penpot.app pnpm run build:types
|
||||
pnpm run start
|
||||
```
|
||||
|
||||
If you want just to update the types definitions with the plugins api doc from the
|
||||
current branch:
|
||||
|
||||
```shell
|
||||
pnpm run build:types
|
||||
```
|
||||
|
||||
(That command will build plugins doc locally and will generate the types yaml from
|
||||
the locally build documentation)
|
||||
|
||||
### 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
|
||||
|
||||
NOTE: only relevant if you are executing this outside of devenv
|
||||
|
||||
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`).
|
||||
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.
|
||||
26
mcp/package.json
Normal file
26
mcp/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "mcp-meta",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"build": "pnpm -r run build",
|
||||
"build:multi-user": "pnpm -r run build:multi-user",
|
||||
"build:types": "./scripts/build-types",
|
||||
"start": "pnpm -r --parallel run start",
|
||||
"start:multi-user": "pnpm -r --parallel --filter \"./packages/*\" run start:multi-user",
|
||||
"bootstrap": "pnpm -r install && pnpm run build && pnpm run start",
|
||||
"bootstrap:multi-user": "pnpm -r install && pnpm run build:multi-user && pnpm run start:multi-user",
|
||||
"fmt": "prettier --write packages/",
|
||||
"fmt:check": "prettier --check packages/"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/penpot/penpot.git"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1",
|
||||
"prettier": "^3.0.0"
|
||||
}
|
||||
}
|
||||
20
mcp/packages/common/package.json
Normal file
20
mcp/packages/common/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"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 --build --clean && tsc --build",
|
||||
"watch": "tsc --watch",
|
||||
"types:check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
]
|
||||
}
|
||||
1
mcp/packages/common/src/index.ts
Normal file
1
mcp/packages/common/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./types";
|
||||
85
mcp/packages/common/src/types.ts
Normal file
85
mcp/packages/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/packages/common/tsconfig.json
Normal file
19
mcp/packages/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"]
|
||||
}
|
||||
24
mcp/packages/plugin/.gitignore
vendored
Normal file
24
mcp/packages/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/packages/plugin/README.md
Normal file
21
mcp/packages/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/packages/plugin/index.html
Normal file
15
mcp/packages/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>
|
||||
24
mcp/packages/plugin/package.json
Normal file
24
mcp/packages/plugin/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "mcp-plugin",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite build --watch --config vite.config.ts",
|
||||
"start:multi-user": "cross-env MULTI_USER_MODE=true vite build --watch --config vite.config.ts",
|
||||
"build": "tsc && vite build --config vite.release.config.ts",
|
||||
"build:multi-user": "tsc && cross-env MULTI_USER_MODE=true vite build --config vite.release.config.ts",
|
||||
"types:check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist/"
|
||||
},
|
||||
"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/packages/plugin/public/manifest.json
Normal file
6
mcp/packages/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"]
|
||||
}
|
||||
425
mcp/packages/plugin/src/PenpotUtils.ts
Normal file
425
mcp/packages/plugin/src/PenpotUtils.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import { Board, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a flex layout to a container while preserving the visual order of existing children.
|
||||
* Without this, adding a flex layout can arbitrarily reorder children.
|
||||
*
|
||||
* The method sorts children by their current position (x for "row", y for "column") before
|
||||
* adding the layout, then reorders them to maintain that visual sequence.
|
||||
*
|
||||
* @param container - The container (board) to add the flex layout to
|
||||
* @param dir - The layout direction: "row" for horizontal, "column" for vertical
|
||||
* @returns The created FlexLayout instance
|
||||
*/
|
||||
public static addFlexLayout(container: Board, dir: "column" | "row"): FlexLayout {
|
||||
// obtain children sorted by position (ascending)
|
||||
const children = "children" in container && container.children ? [...container.children] : [];
|
||||
const sortedChildren = children.sort((a, b) => (dir === "row" ? a.x - b.x : a.y - b.y));
|
||||
|
||||
// add the flex layout
|
||||
const flexLayout = container.addFlexLayout();
|
||||
flexLayout.dir = dir;
|
||||
|
||||
// reorder children to preserve visual order; since the children array is reversed
|
||||
// relative to visual order for dir="column" or dir="row", we insert each child at
|
||||
// index 0 in sorted order, which places the first (smallest position) at the highest
|
||||
// index, making it appear first visually
|
||||
for (const child of sortedChildren) {
|
||||
child.setParentIndex(0);
|
||||
}
|
||||
|
||||
return flexLayout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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/packages/plugin/src/TaskHandler.ts
Normal file
77
mcp/packages/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/packages/plugin/src/main.ts
Normal file
110
mcp/packages/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/packages/plugin/src/plugin.ts
Normal file
69
mcp/packages/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/packages/plugin/src/style.css
Normal file
10
mcp/packages/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/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts
Normal file
212
mcp/packages/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/packages/plugin/src/vite-env.d.ts
vendored
Normal file
4
mcp/packages/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/packages/plugin/tsconfig.json
Normal file
24
mcp/packages/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"]
|
||||
}
|
||||
43
mcp/packages/plugin/vite.config.ts
Normal file
43
mcp/packages/plugin/vite.config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
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"));
|
||||
|
||||
let WS_URI = "http://localhost:4402";
|
||||
console.log("Will define PENPOT_MCP_WEBSOCKET_URL as:", JSON.stringify(WS_URI));
|
||||
|
||||
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: {
|
||||
host: "0.0.0.0",
|
||||
port: 4400,
|
||||
cors: true,
|
||||
allowedHosts: [],
|
||||
},
|
||||
define: {
|
||||
IS_MULTI_USER_MODE: JSON.stringify(process.env.MULTI_USER_MODE === "true"),
|
||||
PENPOT_MCP_WEBSOCKET_URL: JSON.stringify(WS_URI),
|
||||
},
|
||||
});
|
||||
10
mcp/packages/plugin/vite.release.config.ts
Normal file
10
mcp/packages/plugin/vite.release.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig, mergeConfig } from "vite";
|
||||
import baseConfig from "./vite.config";
|
||||
|
||||
export default mergeConfig(
|
||||
baseConfig,
|
||||
defineConfig({
|
||||
base: "./",
|
||||
plugins: [],
|
||||
})
|
||||
);
|
||||
0
mcp/packages/server/.gitignore
vendored
Normal file
0
mcp/packages/server/.gitignore
vendored
Normal file
24
mcp/packages/server/README.md
Normal file
24
mcp/packages/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/packages/server/data/api_types.yml
Normal file
18268
mcp/packages/server/data/api_types.yml
Normal file
File diff suppressed because it is too large
Load Diff
267
mcp/packages/server/data/prompts.yml
Normal file
267
mcp/packages/server/data/prompts.yml
Normal file
@@ -0,0 +1,267 @@
|
||||
# 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:
|
||||
|
||||
* If a board has a layout system, then child positions are controlled by the layout system.
|
||||
For every child, key properties of the child within the layout are stored in `child.layoutChild: LayoutChildProperties`:
|
||||
- `absolute: boolean` - if true, child position is not controlled by layout system. x/y will set *relative* position within parent!
|
||||
- margins (`topMargin`, `rightMargin`, `bottomMargin`, `leftMargin` or combined `verticalMargin`, `horizontalMargin`)
|
||||
- sizing (`verticalSizing`, `horizontalSizing`: "fill" | "auto" | "fix")
|
||||
- min/max sizes (`minWidth`, `maxWidth`, `minHeight`, `maxHeight`)
|
||||
- `zIndex: number` (higher numbers on top)
|
||||
|
||||
* **Flex Layout**: A flexbox-style layout system
|
||||
- Properties: `dir`, `rowGap`, `columnGap`, `alignItems`, `justifyContent`;
|
||||
- `dir`: "row" | "column" | "row-reverse" | "column-reverse"
|
||||
- Padding: `topPadding`, `rightPadding`, `bottomPadding`, `leftPadding`, or combined `verticalPadding`, `horizontalPadding`
|
||||
- To modify spacing: adjust `rowGap` and `columnGap` properties, not individual child positions.
|
||||
Optionally, adjust indivudual child margins via `child.layoutChild`.
|
||||
- When a board has flex layout,
|
||||
- child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true);
|
||||
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".
|
||||
- Add to a board with `board.addFlexLayout(): FlexLayout`; instance then accessible via `board.flex`.
|
||||
IMPORTANT: When adding a flex layout to a container that already has children,
|
||||
use `penpotUtils.addFlexLayout(container, dir)` instead! This preserves the existing visual order of children.
|
||||
Otherwise, children will be arbitrarily reordered when the children order suddenly determines the display order.
|
||||
- Check with: `if (board.flex) { ... }`
|
||||
|
||||
* **Grid Layout**: A CSS grid-style layout system
|
||||
- Add to a board with `board.addGridLayout(): GridLayout`; 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
|
||||
* The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning.
|
||||
Consider converting boards to flex layout when appropriate.
|
||||
|
||||
# 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.
|
||||
54
mcp/packages/server/package.json
Normal file
54
mcp/packages/server/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"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 && cp -r data dist/data",
|
||||
"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",
|
||||
"types:check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist/"
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"penpot",
|
||||
"server"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.24.0",
|
||||
"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": {
|
||||
"@penpot/mcp-common": "workspace:../common",
|
||||
"@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/packages/server/pnpm-lock.yaml
generated
Normal file
2840
mcp/packages/server/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
128
mcp/packages/server/src/ApiDocs.ts
Normal file
128
mcp/packages/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;
|
||||
}
|
||||
}
|
||||
85
mcp/packages/server/src/ConfigurationLoader.ts
Normal file
85
mcp/packages/server/src/ConfigurationLoader.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
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) {
|
||||
this.baseDir = baseDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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/packages/server/src/PenpotMcpServer.ts
Normal file
262
mcp/packages/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(process.cwd());
|
||||
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/packages/server/src/PluginBridge.ts
Normal file
227
mcp/packages/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/packages/server/src/PluginTask.ts
Normal file
122
mcp/packages/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/packages/server/src/ReplServer.ts
Normal file
112
mcp/packages/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/packages/server/src/Tool.ts
Normal file
121
mcp/packages/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/packages/server/src/ToolResponse.ts
Normal file
97
mcp/packages/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/packages/server/src/index.ts
Normal file
67
mcp/packages/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/packages/server/src/logger.ts
Normal file
80
mcp/packages/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/packages/server/src/static/repl.html
Normal file
554
mcp/packages/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/packages/server/src/tasks/ExecuteCodePluginTask.ts
Normal file
22
mcp/packages/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/packages/server/src/tools/ExecuteCodeTool.ts
Normal file
77
mcp/packages/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/packages/server/src/tools/ExportShapeTool.ts
Normal file
147
mcp/packages/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/packages/server/src/tools/HighLevelOverviewTool.ts
Normal file
26
mcp/packages/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/packages/server/src/tools/ImportImageTool.ts
Normal file
123
mcp/packages/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/packages/server/src/tools/PenpotApiInfoTool.ts
Normal file
88
mcp/packages/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/packages/server/src/utils/FileUtils.ts
Normal file
44
mcp/packages/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/packages/server/tsconfig.json
Normal file
22
mcp/packages/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"]
|
||||
}
|
||||
2851
mcp/pnpm-lock.yaml
generated
Normal file
2851
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 @@
|
||||
linkWorkspacePackages: true
|
||||
|
||||
packages:
|
||||
- "./packages/common"
|
||||
- "./packages/server"
|
||||
- "./packages/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 |
45
mcp/scripts/build
Executable file
45
mcp/scripts/build
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# NOTE: this script should be called from the parent directory to
|
||||
# properly work
|
||||
|
||||
SCRIPT_DIR=$(dirname $0);
|
||||
URL=${1:-http://localhost:9090}
|
||||
|
||||
echo "Preparing bundle for types from $URL"
|
||||
|
||||
set -ex
|
||||
|
||||
corepack enable;
|
||||
corepack install;
|
||||
|
||||
# Ensure clean working directory
|
||||
rm -rf dist;
|
||||
rm -rf node_modules;
|
||||
rm -rf packages/server/dist;
|
||||
rm -rf packages/server/node_modules;
|
||||
|
||||
pushd $SCRIPT_DIR;
|
||||
set +e
|
||||
./build-types $URL;
|
||||
set -e
|
||||
popd
|
||||
|
||||
pnpm -r --filter "!mcp-plugin" install;
|
||||
pnpm -r --filter "mcp-server" run build:multi-user;
|
||||
|
||||
rsync -avr packages/server/dist/ ./dist/;
|
||||
|
||||
cp packages/server/package.json ./dist/;
|
||||
cp packages/server/pnpm-lock.yaml ./dist/;
|
||||
|
||||
touch ./dist/pnpm-workspace.yaml;
|
||||
|
||||
cat <<EOF | tee ./dist/setup
|
||||
#/usr/bin/env bash
|
||||
set -e;
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install -P
|
||||
EOF
|
||||
|
||||
chmod +x ./dist/setup;
|
||||
23
mcp/scripts/build-types
Executable file
23
mcp/scripts/build-types
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
SCRIPT_DIR=$(dirname $0);
|
||||
URL=${PENPOT_PLUGINS_API_DOC_URL:-http://localhost:9090}
|
||||
|
||||
pushd $SCRIPT_DIR;
|
||||
|
||||
if [[ "$URL" = "http://localhost:9090" ]]; then
|
||||
pushd ../../plugins
|
||||
pnpm install
|
||||
pnpm run build:doc
|
||||
popd
|
||||
fi
|
||||
|
||||
if [[ "$URL" = "http://localhost:9090" ]]; then
|
||||
pnpx concurrently --kill-others-on-fail -s last -k \
|
||||
"caddy file-server --root ../../plugins/dist/doc/ --listen :9090" \
|
||||
"../types-generator/build $URL";
|
||||
else
|
||||
../types-generator/build $URL;
|
||||
fi
|
||||
|
||||
popd
|
||||
7
mcp/scripts/check
Executable file
7
mcp/scripts/check
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -ex;
|
||||
|
||||
pnpm run fmt:check;
|
||||
pnpm -r run build;
|
||||
pnpm -r run types:check;
|
||||
4
mcp/scripts/fmt
Executable file
4
mcp/scripts/fmt
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -ex;
|
||||
pnpx prettier --write packages/
|
||||
7
mcp/scripts/setup
Executable file
7
mcp/scripts/setup
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e;
|
||||
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm -r install;
|
||||
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
|
||||
26
mcp/types-generator/README.md
Normal file
26
mcp/types-generator/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 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 (optional, already handled by `build` script)
|
||||
|
||||
pixi install
|
||||
|
||||
|
||||
### Buld API types
|
||||
|
||||
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:
|
||||
|
||||
./build <optional-url>
|
||||
|
||||
This will generate `../packages/server/data/api_types.yml`.
|
||||
11
mcp/types-generator/build
Executable file
11
mcp/types-generator/build
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
export URL=${1:-http://localhost:9090}
|
||||
|
||||
SCRIPT_DIR=$(dirname $0);
|
||||
pushd $SCRIPT_DIR;
|
||||
echo "Scrapping $URL..."
|
||||
|
||||
set -e
|
||||
pixi install;
|
||||
pixi run python prepare_api_docs.py $URL;
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user