开源 3D 建筑编辑器 Pascal Editor 的 MCP 服务器:无头运行与两种传输方式怎么选

2026-08-05

本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。

开源 3D 建筑编辑器 Pascal Editor 自带的这台 MCP 服务器,最值得你留意的地方不是”AI 能建模”,而是它把编辑器的数据层和渲染层切开了:Agent 拿到的是一棵完整可读写的场景节点树,但拿不到任何渲染结果——凡是要靠浏览器里的 React 与 Three.js 才能算出来的派生几何,在无头模式下一律不重算。 想清楚这一条,你才知道该把它接在流水线的哪个位置。

先做个必要的消歧:这里说的 Pascal Editor 是 pascalorg/editor 这个开源的 3D 建筑编辑器项目,和 Pascal 编程语言无关,也和压强单位帕斯卡无关。它的 MCP 包发布名叫 @pascal-app/mcp,可执行命令叫 pascal-mcp,许可证是 MIT(Copyright 2026 Pascal Group Inc.)。

站内已有几篇讲 MCP 通用工程问题的文章,分工是这样的:多台 MCP Server 怎么统一管理 讲的是你手上有一堆 server 时的编排与命名冲突,MCP 生产部署 讲的是通用的上线形态与运维,MCP 本地调试 讲的是通用的连通性排错手法;本篇不重复这些通用结论,只顺着 Pascal Editor 这一台具体的 server,讲它自己的无头边界、传输选择和鉴权硬条件。

一、先看它在仓库里长什么样

在动手接之前,花两分钟摸清地形,能省掉后面一半的困惑。

这是一个 Bun 工作区风格的 monorepo。全仓受版本控制的文件 2508 个(git ls-files | wc -l 数得出来),apps/ 下 2 个应用,packages/ 下 9 个包。MCP 服务器只是其中一个包,体量并不大:按各包的受版本控制文件数排,nodes 733、editor 445、core 233、mcp 152、viewer 107。也就是说,MCP 这一层是薄的,真正的重量在节点定义和编辑器里。

packages/nodes/src/ 下有 46 个子目录,每个目录基本对应一类可建模的对象——wall(墙)、slab(楼板,也就是一层楼的水平承重面)、ceilingdoorwindowstairroofzone(房间区域)、level(楼层)、buildingsite,再往下还有 duct-segment(通风管道段)、pipe-fitting(水管接头件)、solar-panelelevator 这类机电设备族——机电指的是建筑里的暖通、给排水、电气这几套管线系统,跟建筑外壳分属不同专业。你可以把它理解成这个编辑器的”数据字典”:Agent 通过 MCP 能造出来的东西,上限就在这里。

wiki/architecture/ 下有 20 份架构文档。根目录同时躺着 AGENTS.md、CLAUDE.md、GEMINI.md 三份 Agent 约定文件——这个项目本身就是按”给 AI 协作者读”的方式在组织的。

packages/mcp/README.md 里,项目对这台服务器的自我定位原话是:它无头地跑在 Bun 里,不需要浏览器、WebGPU、React,也不需要外部数据库服务,并把编辑器界面用的那批场景变更(建墙、放家具、开洞、撤销等)以 MCP 的工具、资源、提示词三种形式暴露出去。这是仓库 README 自己的说法,不是本文替它下的结论,但接下来这几节会在代码里一条条验。

二、“无头”是怎么做到的,代价是什么

无头(headless)指的是没有图形窗口、没有 GPU 渲染上下文,纯进程内跑逻辑。3D 工具能无头,通常意味着它的数据模型和渲染管线是解耦的。Pascal 这边的做法很直接,但也留了一个必须打的补丁。

补丁在 packages/mcp/src/bridge/node-shims.ts。它给 globalThis 装了一个 requestAnimationFrame 的 polyfill(用 setTimeout 顶上),并且注释里写明这个文件必须被最先导入,否则 @pascal-app/core/store 在模块导入阶段就会抛错。原因是 core 的 store 在 updateNodesAction 里用 rAF 批量做脏标记,撤销/重做的 temporal 订阅回调也在用它,而这个订阅是在模块加载时就注册的。

你在 packages/mcp/src/bin/pascal-mcp.ts 的第一行就能看到这个约定被兑现:

// Load shims FIRST so any subsequent core import sees the RAF polyfill.
import '../bridge/node-shims'

这个细节值得你记住。如果你打算绕开 pascal-mcp 这个命令,自己在别的 Node/Bun 进程里嵌入服务器,导入顺序错了会得到一个看起来毫无头绪的启动崩溃。

代价写在 README 的 Limitations 一节,很坦白:墙体斜接(两面墙相交处的接缝处理)、楼板三角化(把多边形切成三角形好交给 GPU 画)、CSG 挖洞(在墙这块实体上做布尔减法挖出门窗洞)、屋顶与楼梯的生成,这些系统都跑在编辑器的 React hooks 里。无头模式不会重新生成这些派生几何,但所有节点数据仍然是完整可操作的。需要真正渲染出来的几何,得在浏览器里跑 @pascal-app/viewer

同一节还列了另外三条你迟早会撞上的边界:export_glb 直接返回 not_implemented(GLB 导出依赖 Three.js 渲染器);core 的 loadAssetUrl / saveAsset 是纯浏览器 API,引用 asset://<id> 的素材在 Node 里解析不了,要用绝对 URL 或 data: URL;dirtyNodes 会在无头模式下持续堆积,因为没有渲染器去消费它,需要的话得自己调 bridge.flushDirty()

三、两种传输方式,各自适合什么

pascal-mcp 的命令行参数在 bin 文件的 HELP 常量里写得很清楚:

USAGE:
  pascal-mcp [--stdio | --http --port <n>] [--scene <path>]

OPTIONS:
  --stdio          Use stdio transport (default)
  --http           Use Streamable HTTP transport
  --port <n>       HTTP port (default 3917)
  --host <host>    HTTP bind host (default 127.0.0.1)
  --auth-token <t> Bearer token required for HTTP calls
  --cors-origin <o> Repeatable allowed HTTP CORS origin
  --scene <path>   Initial scene JSON to load
  --version        Print version
  --help           Print this help

stdio 是默认路径,代码里的判断是”没传 --http 就走 stdio”。packages/mcp/src/transports/stdio.ts 只有十几行,包一层 SDK 的 StdioServerTransport,但它的注释点出了一个铁律:服务器接管了 stdin/stdout 用于 JSON-RPC,调用方所有运维日志必须发到 stderr,否则会污染协议流。bin 文件自己也严格遵守——启动成功那句 [pascal-mcp] stdio server running 走的是 console.error

stdio 的适用场景是:你的 Agent 宿主(Claude Desktop、Claude Code、Codex CLI、Cursor 这类)就在同一台机器上,由宿主负责拉起子进程、管生命周期。这条路上没有端口、没有鉴权、没有跨源问题,进程随宿主生灭。README 给的 Claude Code 配置就是这个形态:

{
  "mcpServers": {
    "pascal": {
      "command": "bunx",
      "args": ["pascal-mcp"],
      "env": {
        "PASCAL_DATA_DIR": "/Users/you/.pascal/data"
      }
    }
  }
}

HTTP 走的是 Streamable HTTP,实现在 packages/mcp/src/transports/http.ts。它用 Node 内置的 http 模块建服务器,把请求直接交给 SDK 的 StreamableHTTPServerTransport.handleRequest(req, res),会话 ID 用 randomUUID() 每连接生成一个,属于有状态模式。默认绑 127.0.0.1,默认端口 3917。

请求进来之前先过一层自建的守卫 createHttpGuard,它做四件事,顺序是固定的:先校验 Origin(不在允许集合里直接 403 origin_not_allowed),再打 CORS 响应头并对 OPTIONS 回 204,然后检查路径——只有 /mcp 放行,其它一律 404 not_found,最后是鉴权与按客户端 IP 的分钟级请求计数(超了返回 429 rate_limited 并带上 Retry-After)。

HTTP 适合的是这几类场景:服务器要长驻、被多个客户端复用;宿主不方便代管子进程(比如容器里跑、或者宿主只支持远端 MCP 端点);你想在同一台服务器上让浏览器里的页面直接连(守卫里对 loopback 来源的 Origin 是无条件放行的,这一点下一节要展开)。

反过来,如果只是一个人在本机跟 Agent 一起建模,stdio 更省事,也天然少一整类攻击面。

下面这张表把你会打交道的几块拆开:

组成部分它负责什么仓库位置你什么时候会碰到它
CLI 入口解析参数、装载初始场景、创建 store 和 server、按参数挑传输、注册 SIGINT/SIGTERM 优雅关停packages/mcp/src/bin/pascal-mcp.ts第一次配置宿主、排查启动失败
stdio 传输把 server 接到 stdin/stdout 的 JSON-RPC 流packages/mcp/src/transports/stdio.ts宿主代管子进程时;日志串进协议流导致连接怪异时
HTTP 传输Streamable HTTP 服务、会话 ID、Origin/CORS/路径/鉴权/限流守卫packages/mcp/src/transports/http.ts服务器长驻、跨机访问、返回 401/403/404/429 时
Node 兼容垫片requestAnimationFrame,必须最先导入packages/mcp/src/bridge/node-shims.ts自己嵌入服务器、导入顺序出错启动崩溃时
本地场景存储用运行时内置 SQLite 驱动开本地库,按环境变量定位路径packages/mcp/src/storage/index.ts想让编辑器和 MCP 共用同一份场景数据时
出网守卫视觉工具取用户给的图片 URL 时挡 SSRFpackages/mcp/src/lib/safe-fetch.tsanalyze_floorplan_image / analyze_room_photo 传远程图片时

四、绑非本机地址时,它强制要什么

这是本篇最该记牢的一条硬规则。connectHttp 在建服务器之前有这么一段:

const host = options.host ?? DEFAULT_HOST
const authToken = options.authToken ?? process.env.PASCAL_MCP_HTTP_TOKEN
if (!(isLoopbackHost(host) || authToken)) {
  throw new Error(
    'HTTP transport on a non-loopback host requires PASCAL_MCP_HTTP_TOKEN or authToken',
  )
}

翻译成人话:只要你把 --host 设成不是环回地址的值(isLoopbackHost 认的是 localhost*.localhost127.0.0.1::1),就必须同时提供一个令牌,来源是 --auth-token 参数或者 PASCAL_MCP_HTTP_TOKEN 环境变量,二者都没有就直接抛错、进程退出。它不会”降级为无鉴权继续跑”,也不会只打个警告。这个设计取向值得称道:把一台能改你磁盘上场景数据的服务器裸暴露到局域网,默认是被禁止的,而不是靠你自觉。

令牌怎么校验也值得看一眼。守卫先取 Authorization: Bearer <token>,取不到再退回自定义头 x-pascal-mcp-token;比较用的是 node:cryptotimingSafeEqual,长度不等直接判否。这挡的是按响应时间逐字节猜令牌的时序攻击。README 给的生成方式是 openssl rand -hex 32

PASCAL_MCP_HTTP_TOKEN="$(openssl rand -hex 32)" \
  pascal-mcp --http --host 0.0.0.0 --port 8787 --cors-origin https://editor.example

Origin 这块有个你必须知道的行为差异。isOriginAllowed 的判断顺序是:来源主机名是环回地址就直接放行;来源与请求的 Host 头同源(http 或 https 任一)也放行;剩下的才查允许集合。允许集合来自 --cors-origin(可重复传)或环境变量 PASCAL_MCP_HTTP_ORIGINS(逗号分隔)。换句话说,Origin 校验挡的是浏览器跨源,不是网络访问控制——一个没有 Origin 头的裸 HTTP 客户端(curl、脚本、另一台机器上的程序)根本不会被这一层拦住,真正拦它的只有令牌。别把 --cors-origin 当成访问白名单用。

还有一层容易忽略:令牌只在 options.authToken 存在时才校验。绑在环回地址且没给令牌时,本机上任何进程、任何用户都能连上这个端口调所有工具。单人开发机通常可以接受,多用户机器或共享开发容器上就不行了。这类”暴露面 vs 便利性”的取舍,可以顺带对照读一下 MCP 的安全边界怎么划Agent 的最小权限设计

五、数据落在哪、Agent 有权改什么

接入之前先把这三件事想清楚,比调通连接重要。

数据落地。 场景通过 MCP 保存时,写进一个本地 SQLite 库,默认路径是 ~/.pascal/data/pascal.dbpackages/mcp/src/storage/index.tscreateSceneStore 注释写明了两个覆盖开关:PASCAL_DB_PATH 指定精确文件路径,PASCAL_DATA_DIR 指定装 pascal.db 的目录。这是纯本地文件,不连任何外部数据库服务——但反过来说,它也就没有任何服务端的备份、审计和回滚,删了就是删了。

编辑器与 Agent 的共享。 当编辑器和 MCP 服务器指向同一个 PASCAL_DATA_DIR 时,Agent 对已保存场景的改动会落进 SQLite,并记录到一条本地 scene_events 事件流;编辑器页面通过 /api/scenes/:id/events 用服务端推送订阅这条流(对应文件是 apps/editor/app/api/scenes/[id]/events/route.ts),所以一个开着的浏览器标签页能跟着 Agent 的编辑实时刷新。存储用 WAL 模式加事务性版本检查来让多个本地进程共用同一个库。每次变更写入前会核对已保存场景的版本号,如果浏览器或另一个 MCP 进程抢先写了更新的版本,工具会返回 live_sync_version_conflict,你得先用 load_scene 重新加载再继续。

Agent 的权限范围。 从 README 的工具表看,除了只读查询(get_scenefind_nodesget_wallsget_zonesmeasure 等)之外,写侧的能力相当彻底:create_roomcreate_walladd_dooradd_windowcut_openingplace_itemfurnish_roomset_zonecreate_levelduplicate_level,以及 apply_patch 这种批量增删改移(先做校验和试运行再提交)。删除侧有 delete_node,带 cascade 参数时会连同子节点一起删。撤销/重做是 undo / redo,README 说变更工具由 Zundo 的 temporal 中间件捕获为单个可撤销步骤——但要注意,这个历史活在进程内存里,进程一重启就没了,它不是数据库层面的版本回滚。

出网。 两个视觉工具 analyze_floorplan_imageanalyze_room_photo 会取你传进去的图片。这条路径上有 packages/mcp/src/lib/safe-fetch.ts 这个 SSRF 守卫:只允许 http/https 协议,拦掉环回、链路本地(含云厂商元数据地址)、各段私有网段,手动跟随重定向并对每一跳重新做同一套检查,同时对响应体大小和请求超时都设了上限。它还支持用 PASCAL_ALLOWED_ASSET_ORIGINS 进一步收窄到指定来源。文件顶部的注释直说了这套防护是补上一个已发现的问题——此前这几个工具是裸 fetch(url)。这类工具还有个前置条件:它们依赖 MCP 宿主支持采样能力(createMessage),不支持的宿主会拿到结构化的 sampling_unavailable 错误。

六、上手与避坑清单

日志写错通道会让连接莫名其妙地坏掉。 stdio 模式下 stdout 是协议流,你自己包一层脚本时随手 console.log 一句,宿主那边看到的是一段解析不了的 JSON-RPC。避法:所有运维输出一律 console.error,跟 bin 文件里的写法保持一致。

导入顺序错了会在启动阶段崩,报错却指向 core。 因为 core 的 store 在模块加载时就注册了用到 rAF 的订阅。避法:只要你自己嵌入服务器,第一行必须是 import '../bridge/node-shims'(或它在你项目里的等价路径),不要让格式化工具或自动 import 排序把它挪到后面。

以为 --cors-origin 是访问白名单。 它只影响带 Origin 头的浏览器请求,且环回来源无条件放行。避法:把网络层访问控制交给令牌和防火墙,--cors-origin 只当浏览器跨源用。

0.0.0.0 忘了令牌,进程直接退出还以为是端口占用。 抛的是一条明确的英文错误信息,但混在启动日志里容易被忽略。避法:先看有没有 requires PASCAL_MCP_HTTP_TOKEN or authToken 这句,有就是缺令牌,不是端口问题。

编辑器和 MCP 各写各的库,然后奇怪”AI 改的东西怎么看不见”。 两边的数据目录默认虽然一致,但只要有一侧设了 PASCAL_DATA_DIRPASCAL_DB_PATH,就会分叉。避法:启动两侧时显式传同一个 PASCAL_DATA_DIR,README 的开发用法就是这么写的。

多方同时改一个场景,写入被拒。 浏览器保存和 MCP 写入会撞版本检查。避法:把 live_sync_version_conflict 当成正常的乐观锁反馈处理——收到就 load_scene 重载再重试,而不是当成 bug 去重试同一个请求。

指望 Agent 建完模直接导出 GLB。 export_glb 会抛 not_implemented。避法:无头侧只用 export_json 拿场景图,渲染和导出交给浏览器里的 viewer。

用外部工具算好坐标灌进来,结果模型是歪的。 README 专门写了一大段:Pascal 是右手坐标系,X 和 Z 构成地面、Y 朝上,长度单位是米、旋转是弧度;你传的二维点都是地面坐标 [x, z],第二个分量是世界坐标的 Z(进深)而不是”高度”。而习惯上”Y 是北、俯视看图”的画法(地形测绘、二维绘图库常见)灌进来会整体转向。避法:按 README 的建议,先在已知锚点放一张标好比例的参考图核对朝向,再决定你这边要补多少旋转。另外墙上挂的东西是墙局部坐标——门窗的 position[0] 和目标为墙时 place_itemposition[0] 都是沿墙的米数,不是平面坐标。

收个尾

判断这台服务器适不适合你的场景,三个问题就够:你需要的是可编程的建筑数据结构,还是渲染出来的画面?如果是后者,无头这条路走不通。你的宿主愿不愿意代管子进程?愿意就用 stdio,不愿意再上 HTTP,并且一旦离开环回地址就老老实实配令牌。你能不能接受场景数据以单个本地 SQLite 文件的形式存在、没有服务端兜底?不能的话,得自己在外面加备份。

接下来该读的文件,按优先级排:packages/mcp/README.md 的 Tools 与 Limitations 两节(决定你能让 Agent 干什么、干不了什么),packages/mcp/src/transports/http.tscreateHttpGuard(决定你的暴露面),packages/mcp/src/storage/index.ts(决定你的数据在哪)。再往深一层,packages/mcp/examples/ 下有可编译的嵌入示例和一份坐标约定的实操演示,wiki/architecture/ 那 20 份文档则是理解节点树本身的入口。想把这台 server 和其它 server 一起编排管理,可以接着看 多台 MCP Server 怎么统一管理

本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 Pascal Editor 上手记:浏览器里的开源 3D 建筑编辑器,装起来要过多包仓库和显卡两道关开源 3D 建筑编辑器 Pascal Editor 的 MCP 工具全景:从建楼层到放家具

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。