Penpot MCP Server 开发指南:WebSocket 架构、Tool/PluginTask 扩展与 devenv 接线
Penpot 的开源仓库中内置了一个基于 Model Context Protocol(MCP)的官方 MCP 服务器子项目,它通过 WebSocket 与运行在 Penpot 浏览器插件环境内的 Penpot MCP Plugin 通信,把设计文件的数据查询、变换与创建能力暴露给 LLM 客户端。本文以该子项目的开发笔记(仓库内 .serena/memories/mcp/core.md)为骨架,结合 mcp/README.md 与相关 TypeScript 源码,系统讲解其整体架构、代码组织、扩展开发流程(新增 Tool / PluginTask)、日常开发命令,以及 Penpot devenv 环境下 MCP 服务的接线方式,帮助你在当前仓库中快速定位代码并开展二次开发。
子项目定位与整体工作方式
mcp/ 目录是 Penpot 仓库中的独立子项目(monorepo)。其核心思路不是让 LLM 直连 Penpot 后端,而是引入一个中间执行层:
- MCP Server(Node.js/TypeScript)向 AI 客户端暴露一组工具(Tool),例如执行代码、查询 API 信息、导出形状等;
- MCP Server 通过 WebSocket 与浏览器中运行的 Penpot MCP Plugin 建立长连接;
- Plugin 在 Penpot 页面内、基于官方 Plugin API 执行任务(本质上是执行 LLM 生成的 JavaScript 代码),再通过同一连接把结构化结果回传。
这样 LLM 可以在设计文件上下文中自由编写并执行代码片段来完成目标。官方把它定位为面向产品团队的 AI 辅助设计链路,使用方式为:MCP Server + AI 客户端、提供 Plugin 的 Web 服务、以及在 Penpot 中加载 Plugin 并点选 “Connect to MCP server”。
技术栈与通用编码原则
根据开发文档,本子项目采用如下技术栈:
- 语言:TypeScript
- 运行时:Node.js
- 框架:MCP SDK(
@modelcontextprotocol/sdk) - 构建:TypeScript Compiler(tsc)+ esbuild
- 包管理:pnpm(
pnpm-workspace.yaml定义了多包 workspace)
开发文档同时给出了两条强制性的编码约定,在 mcp/packages/server/src/Tool.ts 与 mcp/packages/server/src/PluginTask.ts 等文件中可以得到印证:
- 使用惯用的面向对象风格:对于任何非平凡接口,应使用“期望显式类型化抽象”的接口,而不是裸露的函数(例如使用策略模式)。整个代码库正是以
Tool、PluginTask、TaskHandler等抽象基类来组织逻辑的。 - 注释的精确风格:描述参数、方法/函数与类时,首句用省略句(elliptical)形式精确定义“它是什么”,后续句子再补充细节;描述代码块做什么时同样使用省略句、小写字母开头,除非是至少包含两个句子的长解释。
例如 PluginTask.ts 中的注释即为这种风格:Abstract base for plugin tasks, defining the parts that the plugin dispatch and response-correlation machinery ... depend upon. 后跟两段 @template 说明。新代码应保持一致的注释习惯。
代码组织:包结构与职责
开发文档给出了 mcp/ 的目录骨架,结合仓库实际可整理为:
mcp/
├── packages/common/ # 共享类型定义(server 与 plugin 共用)
│ └── src/
│ ├── index.ts # 共享类型导出入口
│ └── types.ts # PluginTaskResult、请求/响应接口等
├── packages/server/ # MCP Server 子项目
│ ├── data/ # 资源:API 信息与 prompt(如 initial_instructions.md)
│ ├── src/
│ │ ├── index.ts # 入口
│ │ ├── PenpotMcpServer.ts # MCP server 实现(连接处理、工具注册等)
│ │ ├── PluginBridge.ts # 与插件 WebSocket 连接的桥接层
│ │ ├── Tool.ts # 工具基类
│ │ ├── PluginTask.ts # 插件任务基类(本地任务)
│ │ ├── RemotePluginTask.ts # 经 Redis 转发的远程任务
│ │ ├── tasks/ # PluginTask 实现(如 ExecuteCodePluginTask)
│ │ └── tools/ # Tool 实现
│ └── package.json
├── packages/plugin/ # Penpot Plugin 子项目
│ ├── src/
│ │ ├── main.ts # 插件 UI iframe 与通信处理
│ │ ├── plugin.ts # 插件主实现、任务分发、版本兼容检查
│ │ └── task-handlers/ # 与 server 端 task 一一对应的 handler
│ └── package.json # 依赖 @penpot/mcp-common
├── types-generator/ # Python 项目,用于生成 API 类型文档
│ └── prepare_api_docs.py
└── docs/
├── multi-user-mode.md # 多用户/多实例模式说明
└── ...
需要说明:开发文档中写作 prepare-api-docs 的 Python 生成器,在当前仓库对应 types-generator(内含 pixi.toml 与 prepare_api_docs.py),其单独说明见 mcp/types-generator/README.md。
共享类型:任务协议的三要素
packages/common 作为 server 与 plugin 共同依赖的“协议层”,定义了任务的全部消息形状(mcp/packages/common/src/types.ts):
PluginTaskRequest:{ id, task, params },其中id是用于请求/响应关联的唯一标识;PluginTaskResponse<T>:{ id, success, error?, data? },success表示插件侧是否成功;PluginTaskResult<T>:承载data?的结果包装;ExecuteCodeTaskParams/ExecuteCodeTaskResultData<T>:executeCode任务专用的参数(code)与结果(result+ 捕获的log)。
服务端两个核心类:PenpotMcpServer 与 PluginBridge
- PenpotMcpServer.ts 负责端口读取、prompt 注入、工具实例化(
initTools)、HTTP 会话管理(Streamable HTTP 的/mcp与旧版 SSE 的/sse)、闲置会话回收(60 分钟超时),以及 REPL 服务的启动。 - PluginBridge.ts 负责插件 WebSocket 服务端(默认端口见下)、连接注册、任务下发与响应关联、心跳与冻结检测、超时处理。
从源码看,端口与模式的读取遵循以下逻辑(默认值与 README 一致):
| 环境变量 | 用途 | 默认 |
|---|---|---|
PENPOT_MCP_SERVER_HOST |
MCP Server 绑定地址 | localhost |
PENPOT_MCP_SERVER_PORT |
HTTP/SSE(MCP 客户端入口) | 4401 |
PENPOT_MCP_WEBSOCKET_PORT |
WebSocket(插件连接入口) | 4402 |
PENPOT_MCP_REPL_PORT |
REPL(开发调试) | 4403 |
PENPOT_MCP_TOOL_TIMEOUT_S |
下发到插件任务的超时秒数 | 120 |
PENPOT_MCP_REMOTE_MODE |
远程模式(true 关闭本机文件系统访问) |
false |
PENPOT_MCP_DEVENV |
开发环境模式(true 额外暴露 Clojure(Script) 开发工具) |
false |
一次任务的完整调用链(执行流程)
理解“工具如何最终变成设计文件上的操作”,是二次开发的起点。完整链路如下:
-
LLM 客户端通过 MCP 协议调用某个 Tool,例如
execute_code。服务端 initTools 预注册工具,其中核心工具(ExecuteCodeTool、HighLevelOverviewTool、PenpotApiInfoTool、ExportShapeTool)无条件注册;ImportImageTool仅在本机模式(文件系统可访问)下注册;而CljsReplTool、ImportPenpotFileTool、CljsCompilerOutputTool、CljCheckParentheses、ReadTaigaIssueTool仅在PENPOT_MCP_DEVENV=true的开发环境中注册。 -
Tool 的
executeCore构造出对应的PluginTask并调用pluginBridge.executePluginTask(task)。以 ExecuteCodeTool 为例,它把参数包成ExecuteCodePluginTask(task 名为executeCode,见 ExecuteCodePluginTask.ts),任务对象内部用 UUID 作为id并通过一个 Promise 等待结果。 -
PluginBridge.sendPluginTask 把任务注册进
pendingTasks索引,随后将PluginTaskRequest序列化后通过 WebSocket 发送给插件;同时启动超时定时器,超时即以“Task ... timed out after ... seconds”拒绝任务。 -
插件侧 main.ts 收到消息后把请求
postMessage给插件主进程;plugin.ts 中的handlePluginTaskRequest依据task名在taskHandlers注册表里查找 handler 并执行。成功时调用task.sendSuccess(...),失败时直接抛出异常由中央逻辑统一处理(会转为带formatTaskError的错误响应)。 -
以
executeCode为例,ExecuteCodeTaskHandler.ts 会把用户代码放进一个持有penpot(Plugin API)、penpotUtils、storage(跨调用持久上下文对象)与捕获型console的上下文对象中执行。它还会临时打开penpot.flags.naturalChildOrdering与penpot.flags.throwValidationErrors两个标志,执行结束后在finally中恢复。最终将{ result, log }通过task.sendSuccess送回。 -
服务端
PluginBridge.handlePluginTaskResponse依据响应中的id找到pendingTasks中对应任务,清除超时定时器,按success调用resolveWithResult/rejectWithError,从而 settle 第 2 步中的 Promise,Tool 再将其转换为 MCP 文本响应返回给 LLM。
连接健康度与浏览器节流的处理
浏览器后台标签页可能被挂起,导致 WebSocket 虽未断开、但页面 JS 无法执行任务。为此连接双方做了三层保障(均可在源码中直接验证):
- 插件侧每 10 秒发送一次
heartbeat应用层消息,并在页面被 Chrome 冻结时发送freeze消息(mcp/packages/plugin/src/main.ts); - 服务端
PluginBridge以 30 秒心跳陈旧阈值判断连接是否“可执行任务”,并在assertPluginResponsive中对 frozen/失活连接给出明确报错(mcp/packages/server/src/PluginBridge.ts); - 插件侧实现带指数退避(1s 起步、封顶 30s)的自动重连,并在标签页恢复可见时立即探测连接。
关键开发任务一:调整系统提示词(Prompts)
LLM 在任务开始前会收到一份“Penpot 高层概览”(Penpot High-Level Overview),其文本文件位于 mcp/packages/server/data/initial_instructions.md。
服务端在构造时会做一次占位符注入:instructions.replace("$api_types", this.apiDocs.getTypeNames().join(", "))(见 PenpotMcpServer.ts),也就是说 Prompt 文件里的 $api_types 会被替换为 Penpot API 类型列表。此外,base_instructions.md 用于生成服务端的基础 instructions。若想调整模型对 Penpot 的整体认知或指令约束,直接修改这两个文件即可,无需改动代码。
关键开发任务二:新增一个 Tool
开发文档给出了两条步骤,与源码实现一一对应:
-
在
mcp/packages/server/src/tools/中实现工具类,遵循Tool接口。注意:不要在executeCore中捕获任何异常,让其向外传播、由中心逻辑统一处理。 事实上 Tool.execute 已经用 try/catch 包裹executeCore,将异常转换成错误文本响应并记录日志,因此子类实现只需聚焦核心逻辑。 -
在
PenpotMcpServer中注册该工具。 具体位置是 PenpotMcpServer.initTools,把工具实例 push 进toolInstances数组即可;实例化时会通过getToolName()/getToolDescription()/getInputSchema()完成自动注册。
Tool<TArgs> 基类(mcp/packages/server/src/Tool.ts)的设计要点:
- 构造时接收一个 zod 的
z.ZodRawShape作为参数 schema,用于向 MCP 协议暴露输入参数;无需参数的工具可直接复用导出的EmptyToolArgs; - 维护一个全局单调递增的
executionCounter,用于为每次执行生成日志 ID; - 提供受保护的
getSessionContext(),可在多用户模式下取到当前请求的会话(含userToken); - 子类只需实现三个抽象方法:
getToolName()、getToolDescription()、executeCore(args)。
“工具可以与在插件中执行的 PluginTask 关联,许多工具都构建在 ExecuteCodePluginTask 之上,因为大量操作可以归结为代码执行”这一设计理念,在工具描述中亦有体现:execute_code 工具提示 LLM 在插件上下文内编写并返回任意 JS 表达式、使用 penpot/penpotUtils/storage 三个对象、并可把中间结果存入 storage 供后续调用复用。
关键开发任务三:新增一个 PluginTask
当一个操作无法(或不适合)用“执行代码”表达时,就需要引入新的任务类型。开发文档给出四步流程:
-
在
mcp/packages/common/src/types.ts中实现任务的输入数据接口。 这一步保证 server 与 plugin 双侧共享同一份类型契约(例如ExecuteCodeTaskParams)。 -
在
mcp/packages/server/src/tasks/中实现PluginTask子类。 可参考 ExecuteCodePluginTask:构造函数中传入task名称与强类型参数,基类 AbstractPluginTask 负责生成 UUID、把任务序列化为PluginTaskRequest,并抽象出resolveWithResult/rejectWithError两个 settle 方法——本地任务通过进程内 Promise 兑现(PluginTask),远程任务则把结果转发回发出方(RemotePluginTask)。 -
在插件侧实现对应的任务处理器类(
mcp/packages/plugin/src/task-handlers/)。- 成功场景:调用
task.sendSuccess; - 失败场景:直接抛出异常,交由中央逻辑统一处理。
该约定在 plugin.ts 的 handlePluginTaskRequest 中落地:handler 找到后先
try { await handler.handle(task) },若 handler 未主动发送响应则补发一个通用成功响应,捕获到的任何异常则统一转成错误响应发送回服务端。
- 成功场景:调用
-
在
mcp/packages/plugin/src/plugin.ts的taskHandlers列表中注册该 handler。 每个 handler 需实现taskType字段与isApplicableTo(task),用于任务名到处理器的路由(参考 ExecuteCodeTaskHandler.ts)。
值得注意的跨端一致性约定:若你新增的任务不需要“跨执行保留状态”或插件 API 特殊性,优先评估能否直接复用 executeCode 以降低维护成本;版本兼容性上,插件启动时会把 penpot.version 与编译期注入的 PENPOT_MCP_VERSION 的 major.minor.patch 前缀做比对,不一致时在插件 UI 显示版本不匹配警告(mcp/packages/plugin/src/plugin.ts),因此引入新的服务端任务时通常意味着 MCP 与 Penpot 版本需同步升级。
日常开发命令
在 mcp/ 目录下运行:
pnpm run build # 构建全部包,验证编译是否通过
pnpm run fmt # 应用自动格式化
首次搭建可执行仓库内的脚本:./scripts/setup 安装依赖(使用 Penpot devenv 时可跳过,因为依赖已装好),随后在子项目根执行 pnpm run bootstrap 会依次完成安装、构建并启动所有组件。测试方面,服务端与插件均带有单元测试,例如 PluginBridge.test.ts、ErrorUtils.test.ts、PenpotUtils.test.ts,可作为验证连接管理与错误处理行为的入口。
devenv 接线:插件如何找到 MCP Server
开发文档特别澄清了一个易混淆的点:在常规 Penpot devenv MCP 路径中,浏览器插件不会通过 Postgres 发现或路由。 具体机制是:
- 前端通过插件扩展 API 的
mcp.getServerUrl()提供给插件服务器地址,其当前实现位于 frontend/src/app/config.cljs:优先取全局penpotMcpServerURI,否则回退为<public-uri>/mcp/ws; - MCP Plugin 直接对该 URL 建立 WebSocket,并把当前 MCP 访问令牌作为查询参数
userToken附加(见 mcp/packages/plugin/src/main.ts 的wsUrl += ?userToken=...)。
由此可以理解服务端的连接模型:
- 插件连接注册表是每个 MCP Server 进程内的内存结构(
PluginBridge.connectedClients与按 token 索引的clientsByToken,见 mcp/packages/server/src/PluginBridge.ts); - 数据库只保存 MCP 访问令牌与
mcp-enabled之类的 profile 属性,并不管理“哪个插件连到了哪个 MCP Server”。单机模式下,插件与 MCP 客户端必须连到同一个 Server 实例;同一 token 只允许一条活跃插件连接,重复连接会被拒绝(错误码 1008)。多实例横向扩展则需要通过PENPOT_MCP_REDIS_URI启用 Redis pub/sub 任务路由(见 mcp/docs/multi-user-mode.md)。
多套并行 devenv 的路由建议
开发文档给出并行开发环境下的推荐做法:优先使用同源(same-origin)MCP 路由——
- 每个 Penpot 实例都应通过自己的 nginx/Caddy 路径把
/mcp/ws暴露给运行在同一主容器内的 MCP Server; - 保持容器内部端口固定(MCP 默认
4401/4402/4403,以及 backend/exporter/frontend 等的默认端口),只对宿主机侧的发布端口做实例级偏移; - 若偏移了内部端口,则像 docker/devenv/files/nginx.conf 这类硬编码的本地代理配置就会错误路由,除非它也做模板化处理。
换言之,路由决策的锚点是“同容器 + 固定内部端口”,数据库与公网地址都不应参与插件发现。这一点在你部署多套 devenv 或排查“插件连上了但任务无响应”时尤其关键。
小结
本子项目的开发主线可概括为三条:以 common/types.ts 为协议契约、以 Tool→PluginTask→TaskHandler 为执行链、以 PluginBridge 的内存连接注册表为路由事实来源。新增能力时先判断能否通过扩展 Prompt、新增 Tool 或新增 PluginTask 实现,再沿上述文件落代码;调试时则从 initial_instructions.md、PenpotMcpServer.initTools 与插件 taskHandlers 三处入手,可以最快定位问题。更多关于远程多用户部署的细节,可继续阅读仓库内的 mcp/docs/multi-user-mode.md 与 mcp/README.md。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0626
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00