开源 3D 建筑编辑器 Pascal Editor 的 MCP 工具全景:从建楼层到放家具
本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。
Pascal Editor 这套 MCP 服务器最值得看的地方,不是它挂了多少个工具,而是它把「Agent 会犯的那几类错」提前编译进了每个工具的入参 schema 和父节点类型检查里——错误在参数解析阶段就被拦下,而不是等到场景被改坏之后再去修。
先做一次消歧:Pascal Editor 是一个开源的 3D 建筑编辑器,跑在浏览器里,仓库根目录的 README 这样定位自己——「A 3D building editor built with React Three Fiber and WebGPU」。它和 Pascal 编程语言无关,也和压强单位帕斯卡无关。仓库以 MIT 许可证发布(Copyright 2026 Pascal Group Inc.)。这个 3D 建筑编辑器里有一个独立的包 packages/mcp,是一台可以单独跑起来的 MCP 服务器,让 AI Agent 直接去建模——本文只谈这台服务器的工具层。
站内已经有几篇讲工具设计通用原则的文章:Agent 工具设计的基本原则 讲的是「一个工具该长什么样」,MCP 工具数量的取舍 讲的是「挂多少个工具模型才不迷路」,工具描述该怎么写 讲的是描述字段的措辞技巧。这篇不重复那些结论,而是拿一个真实的、你现在就能 clone 下来对着看的开源仓库,看这些原则在一个专业领域(三维建筑建模)里被具体落成了什么样子,以及它在哪些地方做了和通用建议不一样的选择。
一、这套工具在解决什么问题
要理解切分逻辑,先得知道被操作的对象长什么样。
Pascal Editor 的场景是一棵节点树。往下走大致是:site(场地)→ building(建筑)→ level(楼层)→ wall(墙)/ slab(楼板,也就是你脚下踩的那块水平混凝土面)/ ceiling(天花)/ zone(区域,一个多边形圈出来的房间范围)→ door、window、item(家具或设备)。每一类节点都有自己的字段和父子关系约束,比如门窗只能挂在墙上,家具不能直接挂在场地上。
packages/mcp/README.md 里写清了这台服务器的定位:它是 Pascal 3D 编辑器的 MCP 服务器,从任何兼容 MCP 的 AI 宿主去驱动 @pascal-app/core 的场景图,在 Bun 里无头运行,不需要浏览器、WebGPU、React 或外部数据库服务。也就是说,编辑器 UI 里那些鼠标操作能干的事——建墙、放物件、开洞、撤销——被原样暴露成了 MCP 工具。
问题就在这儿:一个 Agent 拿到「帮我在二楼加个卧室」这种指令,它不懂建筑规范,也不知道这棵树的父子约束。如果工具层是一个「传一段 JSON 进来我照做」的口子,模型迟早会把门挂到楼板上、把家具塞进场地节点、或者在屋顶支撑层上砌墙。这台服务器的做法是反过来:把这些错误一条条写进工具签名,让它们在参数解析阶段就返回结构化错误,模型看到报错文本能自己改。
二、工具是怎么切的:按节点类型的约束切,不按动词切
注册入口在 packages/mcp/src/tools/index.ts,一个 registerTools(server, operations) 函数把所有非视觉类工具挂上去。数一下这个函数体:无条件调用 23 个 register* 函数,另外 3 个包在条件里:
if (operations.hasStore) {
registerSceneLifecycleTools(server, operations)
registerVariantTools(server, operations)
registerPhotoToSceneTool(server, operations)
}
hasStore 为假时,场景存档、方案变体、照片转场景这三组工具压根不会出现在 tools/list 里。这是个很值得学的细节:能力不可用时不是留着工具再在运行时报错,而是直接不注册——模型看不到,就不会浪费一轮调用去试。文件顶部的注释还说明了另一件事:视觉类工具 analyze_floorplan_image 和 analyze_room_photo 由单独的 registerVisionTools 注册,不走这条主链。
有些 register* 函数是一对一的(registerCreateWall 只注册一个工具),有些是一对多的打包注册器:construction-tools.ts 里一口气注册 create_story_shell、create_roof、create_stair_between_levels;room-tools.ts 里是 search_assets、create_room、add_door、add_window、furnish_room;scene-query.ts 里是 list_levels、get_level_summary、get_walls、get_zones、verify_scene。在 packages/mcp/src/tools 目录下用正则扫一遍 registerTool('...') 的字面量,能数出 46 个工具名,没有重名。
真正决定切分粒度的,是「这次操作要不要检查一个不同的父节点类型」。create_wall 检查父节点必须是 level,cut_opening 检查父节点必须是 wall,create_level 检查父节点必须是 building,place_item 检查目标必须是 level / slab / zone / wall / ceiling 五者之一。约束不同,就是不同的工具;约束相同的操作才有可能被打包进同一个注册器。
三、四个代表工具的入参出参
先看最短的一个。packages/mcp/src/tools/create-wall.ts 里的入参定义只有五行:
export const createWallInput = {
levelId: NodeIdSchema,
start: Vec2Schema,
end: Vec2Schema,
thickness: measurement('length', 'm', {
positive: true,
description: 'Wall thickness.',
}).optional(),
height: measurement('length', 'm', { positive: true, description: 'Wall height.' }).optional(),
}
出参就一个字段 wallId: z.string()。工具描述写得很克制:在给定楼层的两个二维点之间建一面墙,厚度和高度省略时走 core 库的默认值。
NodeIdSchema、Vec2Schema 这些共享片段全在 packages/mcp/src/tools/schemas.ts 里。这个文件只有五十来行,但有一处注释值得单独拎出来——二维点为什么不用 z.tuple():
/**
* 2D point as [x, z] (floor plane). Use array length constraints instead of
* `z.tuple()` so MCP hosts that only accept JSON Schema's common `items` shape
* can register the tools.
*/
export const Vec2Schema = z.array(z.number()).min(2).max(2)
z.tuple() 生成的 JSON Schema 里 items 是一个数组(逐位置定型),而部分 MCP 宿主只认 items 是单个对象的那种形式,遇到前者会直接注册失败。这是纯粹的互操作性妥协,不是代码风格问题。注意坐标是 [x, z]——地面平面用的是 x 和 z 两轴,y 留给高度。
再看 cut_opening(在墙上挖一个门洞或窗洞)。它的入参里有一个设计得很讲究的参数:
wallId: NodeIdSchema,
type: z.enum(['door', 'window']),
position: z.number().min(0).max(1),
position 是 0 到 1 的参数化偏移量——0 是墙的起点,1 是终点,0.5 是正中间。为什么不直接让模型传米数?因为模型不知道这面墙多长,传米数就得先查一次墙长再算,多一轮往返还容易算错。而门窗节点内部其实存的是墙局部坐标系里的米数,这个转换由工具内部做掉了,源码里的注释把这层意图写得很直白:position 是给 MCP 用的人机工学参数,节点存的是墙局部米数,写入前转换。同时还有一道硬校验:
const length = wallLength(wall)
if (length < width) {
throwMcpError(
ErrorCode.InvalidParams,
`Wall ${wallId} is ${length.toFixed(2)}m long, too short for a ${width.toFixed(2)}m opening`,
)
}
报错文本里带上了实际墙长和请求宽度。这种错误信息对 Agent 是可读的——它能直接算出该改成多少,而不是收到一句「参数无效」后瞎猜。
place_item 是四个里最复杂的。入参是 catalogItemId、targetNodeId、position(三维点)和可选的 rotation;出参除了 itemId 还多一个可选的 status。它的目标类型检查写得很有意思:
throwMcpError(
ErrorCode.InvalidRequest,
`Cannot place item on ${targetType}; target must be a level, slab, zone, wall, or ceiling. Site-level placement is not supported yet because site.children is reserved for buildings.`,
)
错误信息不只说了「不行」,还说了「为什么不行」——site 的 children 被保留给建筑了。而且当目标是墙时,工具会把传进来的世界坐标投影成墙局部的 x,再算出一个 0 到 1 的 wallT 一起写进节点,调用方完全不用关心这层换算。status 字段则用来表达一种半成功:目录里找不到这个 catalogItemId 时,它不报错,而是塞一个占位资产进去,把 status 置成 catalog_unavailable。这个取舍是「先让场景结构立起来,资产可以后补」,配套的做法是 search_assets 工具的描述里明写着——需要一个合法的 catalogItemId 时,先调它。
度量参数:这个项目做得最不一样的一处
measurement.ts 里的封装是我在这个仓库里觉得最值得单独学的一段。它给长度和角度参数生成的 JSON Schema 是 number | string,意思是模型既可以传 0.15,也可以传 "6 in"、"180cm"、"2 ft 3 in";角度可以传 45、"45°"、"1.57rad"、"0.25 turn"。字符串会在 Zod 的 transform 里被解析并统一换算成工具处理函数期望的那个单位,处理函数一行都不用改。
理由文件里写了:模型是用哪个单位思考的,就让它用哪个单位回答。强迫模型先做单位换算,等于在最容易出错的地方加了一道人肉计算。
但它同时留了一道硬门槛,值得抄:
// A genuinely ambiguous separator ("1,234") must not silently become
// a 1000× value at a tool boundary — fail so the model self-corrects.
escalate: { AMBIGUOUS_NUMBER: 'error' },
"1,234" 在不同地区可能是 1234 也可能是 1.234,差一千倍。这种真歧义不做猜测,直接失败,让模型自己纠正。在工具边界上「宽容地接受」和「绝不猜测」这两件事并不矛盾——能确定语义的多接一点,不能确定的一律拒绝,这个分界画得非常干净。相关的通用讨论可以看 Agent 工具的参数校验。
角度字段还有个细节:单位是弧度时,示例里第一个给的是 1.5708 而不是 90°,注释说明这是为了让裸数字被按字段自己的单位读——弧度字段里的裸数字 45 就是 45 弧度,不是 45 度。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 注册入口 | 把所有非视觉工具挂到 server 上,按 hasStore 条件裁剪 | packages/mcp/src/tools/index.ts | 想知道到底有哪些工具、哪些是可选的 |
| 共享 schema | 节点 id、二维/三维点、补丁操作的判别联合类型 | packages/mcp/src/tools/schemas.ts | 新增工具时复用;排查宿主注册失败 |
| 度量字段封装 | 让长度/角度参数同时接受数字和自然语言 | packages/mcp/src/tools/measurement.ts | 模型用英尺、厘米回答时 |
| 单体写工具 | 一个操作一个文件,各自做父类型校验 | create-wall.ts、cut-opening.ts、place-item.ts、create-level.ts、set-zone.ts | 学一个工具该怎么写 |
| 打包注册器 | 一组同约束操作放一个文件 | construction-tools.ts、room-tools.ts、scene-query.ts | 找 create_room、furnish_room 这类复合工具 |
| 通用兜底工具 | 批量增删改,原子提交 | packages/mcp/src/tools/apply-patch.ts | 细粒度工具覆盖不到的改动 |
| 错误封装 | 抛协议级错误 / 返回内联失败载荷 | packages/mcp/src/tools/errors.ts | 决定一个失败该以哪种形态回给模型 |
| 写后同步 | 每次写操作后落盘并追加实时事件 | packages/mcp/src/tools/live-sync.ts | 关心数据落在哪、浏览器怎么看到更新 |
| 校验类工具 | 全量 Zod 校验、家具碰撞检测 | validate-scene.ts、check-collisions.ts、layout-clearance.ts | 让 Agent 自查产出 |
四、为什么不做一个万能工具:对照就在同一个目录里
这个仓库有个特别方便做判断的地方——它同时提供了两种做法,就摆在一个目录下。apply_patch 就是那个「万能工具」:
export const applyPatchInput = {
patches: z.array(PatchSchema),
}
export const applyPatchOutput = {
appliedOps: z.number(),
deletedIds: z.array(z.string()),
createdIds: z.array(z.string()),
}
PatchSchema 是 create / update / delete 三种操作按 op 字段做的判别联合类型。create 分支里节点对象的类型是 z.record(z.string(), z.unknown())——一个字段随意的对象,schemas.ts 的注释解释了原因:这里先收下宽松的对象,结构问题留给桥接层的 Zod 再解析一次去抓。工具描述里承诺了两件事:整批先全部校验再全部应用,以及整批算一步撤销。
对比之后,细粒度切法的好处就具体了,不用停留在「更清晰」这种空话上:
第一,父类型校验只能长在细粒度工具里。apply_patch 只知道 parentId 是个字符串,它不知道你打算把什么挂到什么下面,所以给不出「屋顶支撑层不是可居住楼层,请在可居住楼层上砌墙」这种针对性提示。而 create_wall 里明确读了父节点 metadata.role 是不是 roof 并拒绝。
第二,参数可以做人机工学翻译。cut_opening 的 0 到 1 参数化位置、place_item 的世界坐标转墙局部坐标,都是在工具边界上把「模型好表达的形式」翻译成「节点要存的形式」。走 apply_patch 就意味着模型必须自己算这些数,算错了没人拦。
第三,出参能收窄成可直接消费的形状。create_wall 只回一个 wallId,create_room 回的是 zoneId、slabId、ceilingId 加一个按多边形边序排列的 wallIds——顺序本身就是信息,Agent 拿到之后可以直接按「第几面墙」去开门。apply_patch 只能回一个笼统的 createdIds 数组,谁是谁得自己对。
第四,撤销粒度不一样。apply_patch 一批算一步撤销,这在批量导入时是优点,但 Agent 试探性调整时反而不好回退。
那 apply_patch 存在的意义是什么?它是逃生口。工具集永远覆盖不完所有改动,尤其是「批量改一百面墙的材质」这种既没有专用工具、又不值得为它写一个的场景。这个组合——细粒度工具承担八成常见路径并在边界上做校验和翻译,通用工具兜住剩下的两成——比二选一都更实用。这一点和 Agent 工具返回值该怎么设计 里讲的返回形状决定下一步动作,是同一个道理的两面。
顺带说一个跨越粒度的复合工具 create_room:它一次创建 zone、slab、ceiling 和多边形每条边一面墙,内部是走 bridge.applyPatch 一次性提交的。所以细粒度和批量并不对立——细粒度是对外的接口形状,批量是对内的提交方式。
五、边界与代价:它明确不管的事
这套设计的收益是清楚的,代价也一样清楚。
它不做建筑合规判断。 有 check_collisions 检测家具在平面上的包围盒重叠(源码写明是旋转感知的平面 AABB 测试,AABB 就是包住物体的那个轴对齐矩形),有 door-clearance.ts、layout-clearance.ts 处理门前净空——净空指的是门开合和人通行需要留出的那块不能放东西的空地。但这些是几何层的检查,不是规范层的。它不会告诉你楼梯踏步高度是否合规、疏散宽度够不够、采光面积达不达标。
validate_scene 校验的是数据结构,不是设计合理性。 它对场景里每个节点跑一遍 Zod 校验,返回 { valid, errors },每条错误带 nodeId、path、message。一个所有字段都合法、但门开在承重墙正中的方案,它照样判 valid。让 Agent 自查是有价值的,但别把「valid」当成「这个设计能用」。
写操作会立刻落到本地磁盘。 每个写工具的最后一步都是 publishLiveSceneSnapshot(bridge, 'create_wall') 这种调用——保存场景并追加一条实时事件给浏览器订阅者。存储位置在 packages/mcp/src/storage/index.ts 的注释里写得很明确:默认写到 ~/.pascal/data/pascal.db,可以用 PASCAL_DB_PATH 指定确切文件路径,或用 PASCAL_DATA_DIR 指定包含 pascal.db 的目录。也就是说,你把这台服务器接进 Agent,Agent 的每一次建模动作都会真实修改你 home 目录下的一个 SQLite 文件。这不是沙箱,撤销靠的是 undo 工具而不是「反正没落盘」。
并发写有版本冲突。 live-sync.ts 捕获 SceneVersionConflictError 并转成 live_sync_version_conflict 错误。编辑器和 MCP 服务器共享同一个数据库时,两边同时改一个场景就会撞上,这条错误会返回给模型。
HTTP 传输意味着开端口。 transports/http.ts 里默认绑定 127.0.0.1,并且有一条硬约束:绑非回环地址时必须提供 PASCAL_MCP_HTTP_TOKEN 或 authToken,否则直接抛错拒绝启动。它还有按客户端的每分钟请求上限和 PASCAL_MCP_HTTP_ORIGINS 的 CORS 白名单。这些默认值是保守的,但保守的默认值只在你不去改它的时候有效——一旦你为了给同事演示而把 host 改成 0.0.0.0,暴露出去的是一个能任意读写你本地场景库的接口。
它可能出网。 视觉类工具接受调用方给的图片 URL,服务器要去把图片拉下来——这就意味着模型能指挥你的机器发起 HTTP 请求。仓库为此单独写了 packages/mcp/src/lib/safe-fetch.ts,注释里把防的东西列得很清楚:SSRF(服务端请求伪造,指攻击者借服务器之手去访问它自己够不着的内网地址)。它拦回环地址、链路本地地址(云厂商的实例元数据接口就挂在这一段上,是最典型的凭据泄露入口)、各段私有网段、非 http(s) 协议,并且对重定向的每一跳重新套一遍同样的判断,还带上了响应体大小上限和请求超时。另有一个可选的环境变量 PASCAL_ALLOWED_ASSET_ORIGINS,用逗号分隔来源,设了之后只允许列表里的 origin;不设时它就不生效,也就是说默认放行任何通过了前面那几道内网判断的公网地址。文件注释还老实记了这套防护是补上去的——在此之前那几个视觉工具是直接裸调 fetch(url) 的。
六、上手与避坑清单
别跳过 tools/list 就调工具。 output-schema-contract.test.ts 顶部的注释把这个坑写得非常完整:真实 MCP 宿主都会先调 tools/list,SDK 客户端缓存了工具的输出 schema 之后,会用 additionalProperties: false 去校验 structuredContent。结果就是——payload 里多一个 schema 没声明的字段,生产环境直接以 -32602 失败,而所有「不先列工具就直接调」的测试全绿。为什么会踩:你本地的集成测试往往图省事跳过了 list 这一步。怎么避:写一条先 listTools() 再逐个调用的契约测试,这个仓库就是这么做的。
新增工具时先去 schemas.ts 找有没有现成片段。 为什么会踩:复制一个已有工具改改是最快的写法,很容易顺手把 z.array(z.number()) 手写一遍。怎么避:文件顶部的注释就是规矩——一个形状被超过一个工具引用,就定义在这里。手写副本的风险是某天共享 schema 改了而你的副本没跟上。
长度参数不要自己写 z.number()。 为什么会踩:看到入参是米就直觉写数字类型。怎么避:用 measurement('length', 'm', ...),模型才能用它顺手的单位回答。还有一条来自源码注释的提醒:这份封装被刻意复制了一份给另一套「AI 对话工具栈」用,注释写明两套工具栈分属不同的包(其中一个包不在这个仓库里,本仓库是以子模块形式被外层项目引用的那一半),所以没法共享模块,要求两份手工保持一致,并把长期正解记成「从量纲解析库里导出一个 zod 字段适配器」。你在这个仓库里只能改到 MCP 这一份——所以如果你 fork 的是整个外层项目,改完记得同步另一份。
别用坐标去指定门窗位置。 为什么会踩:习惯了三维软件的人第一反应是给世界坐标。怎么避:cut_opening 的 position 是 0 到 1 的参数化值,直接给比例;place_item 目标是墙时也会自动做投影,你给的世界坐标会被换算掉。
放家具前先搜目录。 为什么会踩:place_item 传一个不存在的 catalogItemId 不会报错,它会静默放一个占位资产,只在 status 里给你 catalog_unavailable。如果你的编排代码只看有没有抛异常,这个信号就丢了。怎么避:调用链里先 search_assets 拿合法 id,并且在编排层显式判断 status。
给 Agent 配这台服务器前,先决定数据目录。 为什么会踩:默认路径 ~/.pascal/data/pascal.db 是共享的,编辑器和 MCP 服务器指向同一个文件时,Agent 的动作会实时影响你正在看的编辑器窗口——这有时是你要的效果,有时不是。怎么避:用 PASCAL_DATA_DIR 给 Agent 单独开一个目录做实验,确认稳定了再合到主库。
能力不可用就别注册工具。 为什么会踩:习惯性把所有工具都挂上,运行时再报「功能未启用」。怎么避:照着 registerTools 里那个 hasStore 判断做——工具不出现在列表里,模型就不会浪费一轮调用,也不会被一个永远失败的工具带偏。
收束
如果你要把这套东西迁到自己的领域,按这个顺序读源码效率最高:packages/mcp/src/tools/index.ts 看全景和条件注册;schemas.ts 看共享片段的边界在哪;create-wall.ts 看一个最短的完整工具长什么样;cut-opening.ts 看参数的人机工学翻译;place-item.ts 看复杂目标校验和半成功状态;最后 apply-patch.ts 看通用逃生口该留成什么形状。
判断自己的工具切分是否到位,可以对着这几条自查:每个写工具是否都有一条只属于它的父类型或前置条件校验?错误文本里有没有带上模型自我纠正所需的具体数值?出参形状能不能直接喂给下一步调用,还是需要调用方再查一次?有没有留一个通用兜底工具,以及它的撤销粒度你能接受吗?最后一条最容易被忽略——这套工具会往哪个文件写数据,你说得出确切路径吗?
本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 开源 3D 建筑编辑器 Pascal Editor 的 MCP 服务器:无头运行与两种传输方式怎么选 和 开源浏览器端 3D 建筑编辑器 Pascal Editor:Agent 建的模型存在哪,本地 SQLite 与版本校验约定。