首页
/ Penpot MCP Server 开发指南:WebSocket 架构、Tool/PluginTask 扩展与 devenv 接线

Penpot MCP Server 开发指南:WebSocket 架构、Tool/PluginTask 扩展与 devenv 接线

2026-09-07 11:42:54作者:毕习沙Eudora

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.tsmcp/packages/server/src/PluginTask.ts 等文件中可以得到印证:

  1. 使用惯用的面向对象风格:对于任何非平凡接口,应使用“期望显式类型化抽象”的接口,而不是裸露的函数(例如使用策略模式)。整个代码库正是以 ToolPluginTaskTaskHandler 等抽象基类来组织逻辑的。
  2. 注释的精确风格:描述参数、方法/函数与类时,首句用省略句(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.tomlprepare_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

一次任务的完整调用链(执行流程)

理解“工具如何最终变成设计文件上的操作”,是二次开发的起点。完整链路如下:

  1. LLM 客户端通过 MCP 协议调用某个 Tool,例如 execute_code。服务端 initTools 预注册工具,其中核心工具(ExecuteCodeTool、HighLevelOverviewTool、PenpotApiInfoTool、ExportShapeTool)无条件注册;ImportImageTool 仅在本机模式(文件系统可访问)下注册;而 CljsReplToolImportPenpotFileToolCljsCompilerOutputToolCljCheckParenthesesReadTaigaIssueTool 仅在 PENPOT_MCP_DEVENV=true 的开发环境中注册。

  2. Tool 的 executeCore 构造出对应的 PluginTask 并调用 pluginBridge.executePluginTask(task)。以 ExecuteCodeTool 为例,它把参数包成 ExecuteCodePluginTask(task 名为 executeCode,见 ExecuteCodePluginTask.ts),任务对象内部用 UUID 作为 id 并通过一个 Promise 等待结果。

  3. PluginBridge.sendPluginTask 把任务注册进 pendingTasks 索引,随后将 PluginTaskRequest 序列化后通过 WebSocket 发送给插件;同时启动超时定时器,超时即以“Task ... timed out after ... seconds”拒绝任务。

  4. 插件侧 main.ts 收到消息后把请求 postMessage 给插件主进程;plugin.ts 中的 handlePluginTaskRequest 依据 task 名在 taskHandlers 注册表里查找 handler 并执行。成功时调用 task.sendSuccess(...),失败时直接抛出异常由中央逻辑统一处理(会转为带 formatTaskError 的错误响应)。

  5. executeCode 为例,ExecuteCodeTaskHandler.ts 会把用户代码放进一个持有 penpot(Plugin API)、penpotUtilsstorage(跨调用持久上下文对象)与捕获型 console 的上下文对象中执行。它还会临时打开 penpot.flags.naturalChildOrderingpenpot.flags.throwValidationErrors 两个标志,执行结束后在 finally 中恢复。最终将 { result, log } 通过 task.sendSuccess 送回。

  6. 服务端 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

开发文档给出了两条步骤,与源码实现一一对应:

  1. mcp/packages/server/src/tools/ 中实现工具类,遵循 Tool 接口。注意:不要在 executeCore 中捕获任何异常,让其向外传播、由中心逻辑统一处理。 事实上 Tool.execute 已经用 try/catch 包裹 executeCore,将异常转换成错误文本响应并记录日志,因此子类实现只需聚焦核心逻辑。

  2. 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

当一个操作无法(或不适合)用“执行代码”表达时,就需要引入新的任务类型。开发文档给出四步流程:

  1. mcp/packages/common/src/types.ts 中实现任务的输入数据接口。 这一步保证 server 与 plugin 双侧共享同一份类型契约(例如 ExecuteCodeTaskParams)。

  2. mcp/packages/server/src/tasks/ 中实现 PluginTask 子类。 可参考 ExecuteCodePluginTask:构造函数中传入 task 名称与强类型参数,基类 AbstractPluginTask 负责生成 UUID、把任务序列化为 PluginTaskRequest,并抽象出 resolveWithResult / rejectWithError 两个 settle 方法——本地任务通过进程内 Promise 兑现(PluginTask),远程任务则把结果转发回发出方(RemotePluginTask)。

  3. 在插件侧实现对应的任务处理器类(mcp/packages/plugin/src/task-handlers/)。

    • 成功场景:调用 task.sendSuccess
    • 失败场景:直接抛出异常,交由中央逻辑统一处理。 该约定在 plugin.ts 的 handlePluginTaskRequest 中落地:handler 找到后先 try { await handler.handle(task) },若 handler 未主动发送响应则补发一个通用成功响应,捕获到的任何异常则统一转成错误响应发送回服务端。
  4. mcp/packages/plugin/src/plugin.tstaskHandlers 列表中注册该 handler。 每个 handler 需实现 taskType 字段与 isApplicableTo(task),用于任务名到处理器的路由(参考 ExecuteCodeTaskHandler.ts)。

值得注意的跨端一致性约定:若你新增的任务不需要“跨执行保留状态”或插件 API 特殊性,优先评估能否直接复用 executeCode 以降低维护成本;版本兼容性上,插件启动时会把 penpot.version 与编译期注入的 PENPOT_MCP_VERSIONmajor.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.tsErrorUtils.test.tsPenpotUtils.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.tswsUrl += ?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 为协议契约、以 ToolPluginTaskTaskHandler 为执行链、以 PluginBridge 的内存连接注册表为路由事实来源。新增能力时先判断能否通过扩展 Prompt、新增 Tool 或新增 PluginTask 实现,再沿上述文件落代码;调试时则从 initial_instructions.mdPenpotMcpServer.initTools 与插件 taskHandlers 三处入手,可以最快定位问题。更多关于远程多用户部署的细节,可继续阅读仓库内的 mcp/docs/multi-user-mode.mdmcp/README.md

登录后查看全文
热门项目推荐
相关项目推荐