资讯详情 MCP Server实战:让AI直接操作3D场景的完整实现方案
📅 2026/10/11 13:51:33
把 AI 接进 3D 场景方法其实五花八门有人靠插件脚本有人把场景导出成图片让 AI 看也有人让 AI 只生成代码再由人来跑。绕了一圈我最终选择了 MCP server——用一个标准协议把 AI 的意图翻译成 3D 场景里的真实操作。这篇文章是我的完整实现记录从协议选择、工具设计、代码落地到联调排错全程基于我自己的一个轻量 3D 场景管理系统。如果你是搞三维工具链、数字孪生、或单纯想在个人项目里让 AI 帮忙改场景可以直接照抄这套思路。1. 为什么要给 3D 世界装一个 MCP server1.1 想让 AI 直接动手改场景做三维内容的人应该都有这种感觉AI 聊天再流畅到了真正动手的时候还是隔着一层。你让它“在场景里放一张桌子桌上放一个杯子”它确实能给你写出一大段描述甚至生成一段伪代码但场景文件本身不会因为你这段对话就发生变化。我最早尝试的方案是让 AI 输出 Python 脚本然后把脚本喂给三维软件的脚本面板去执行。听起来可行落地却很难受。模型不熟悉你内部的数据结构经常生成一个只存在于它想象中的 API脚本一旦报错它看不到完整日志只能靠人回贴报错信息来回几次效率很低。更致命的是这种方式完全没有“边界”概念AI 生成的脚本理论上可以碰文件系统、碰系统命令你根本不敢把完整权限交给它。后来我把思路换成了“工具调用”AI 不直接写代码而是调用一组我已经定义好的函数每个函数都有清晰的入参和返回值。函数内部怎么做对 AI 来说是一个黑盒它只需要按 JSON Schema 提交参数。这样做的好处是职责清晰权限可控参数还能校验。1.2 MCP 解决了什么如果只是自己定义一组函数其实没必要用 MCP 这种协议。麻烦在于AI 客户端不知道你这组函数长什么样函数数量一多你还需要自己处理模型选工具、传参、错误恢复这些环节。MCPModel Context Protocol做的就是把“工具发现、参数校验、请求响应、错误协议”这些通用机制标准化。AI 客户端启动时会主动向 MCP server 要一份工具清单包含工具名称、描述、参数 schema模型看到这份清单后按需决定调用哪个工具。你不需要为每个 AI 客户端单独写适配层只要暴露一个 MCP server支持 MCP 的客户端都能直接接入。我实际体验下来这相当于给 3D 场景装了一个“标准插座”。场景内核负责三维数据MCP server 负责翻译 AI 的意图两边通过 JSON-RPC 通信互不干扰。后续想换场景引擎或者换 AI 客户端都只需要改一小块代码。2. 整体方案设计协议、传输与工具面2.1 先搞清楚 MCP 到底在传输什么动手写代码之前我花了一晚上把 MCP 的协议语义理清楚。虽然官方 SDK 封装了大多数细节但如果不理解底层遇到问题会非常被动。MCP 里最有用的概念是 tools。一个 tool 就是一个可以被大模型调用的函数它有明确的 name、description、inputSchema。AI 客户端负责根据对话上下文决定“该不该调”MCP server 负责“能不能调、怎么调”。通信格式是 JSON-RPC 2.0。完整流程大概是客户端发起 initialize做协议版本协商。客户端发 tools/listserver 返回所有工具定义。客户端根据工具定义构造参数发 tools/call。server 执行函数返回文本内容或结构化错误。这里有个容易忽略的细节工具返回给模型的内容不是专门给用户看的界面而是模型“下一轮决策”的输入。模型会根据返回值判断操作是否成功再决定要不要继续调用别的工具。因此返回值必须是干净、结构化、机器可读的别把无关日志掺进去。2.2 stdio 还是 SSE传输层怎么选MCP server 支持的传输方式有很多我实际用下来主要是 stdio 和 SSE 两种。stdio 适合本地工具场景AI 客户端直接以子进程方式拉起 server进程生命周期由客户端管理配置简单启动快。SSE 适合 server 跑在另一台机器、或同时服务多个客户端的情况通信走 HTTP 长连接能做远程调用。对比项stdioSSE部署位置本机进程远程服务启动方式客户端拉起独立启动多客户端一般一对一可多客户端鉴权不适用需要处理典型场景桌面 AI 客户端接入网页端、团队协作我的个人项目选择 stdio 起步理由很简单业务逻辑在本地不需要跨网络传输调试也方便。后来为了给一个网页端预览工具用才加了一条 SSE 启动入口。两个入口共用同一套工具函数只是最后的 mcp.run 参数不一样。2.3 3D 场景内核约定MCP 是胶水层真正干活的是 3D 场景内核。我没用现成的重型引擎而是写了一个轻量的场景图。每个物体是一个节点节点拥有名字、类型、坐标、缩放、材质、包围盒信息。为了让模型不“胡猜”我在设计阶段就固定了几个约定坐标系右手系Y 轴向上X 向右Z 向外。单位所有位置和尺寸都使用米。颜色统一用 6 位十六进制比如#ff0000。命名场景物体名最终会归一化为小写蛇形命名。这些约定不只是给自己看的每条都要写进工具 description。模型没有默认单位感如果你不告诉它“单位是米”它可能给你传一个“1”表示一像素也可能传一个“2”表示两英尺完全不可控。3. 工具集拆解AI 最需要哪些 3D 操作3.1 最小可用工具集我没有一开始就把所有功能都暴露给 AI而是先定义了六个最小可用工具。工具不在多在于覆盖完整“编辑闭环”能创建、能修改、能查询、能删除、能导出。工具名作用关键参数add_primitive添加基础体name, primitive, x/y/z, size, colortransform_object平移物体name, dx, dy, dzset_material修改颜色/粗糙度name, color, roughnessquery_scene查询场景结构patterndelete_object删除物体nameexport_scene导出场景文件path这里面 query_scene 是我最看重的。AI 做决策前需要“看到”当前场景状态如果场景里已经有一张桌子你再让它放杯子它必须先查出桌子顶面的坐标而不是凭空生成一个杯子位置。大部分失败都源于模型对场景状态一无所知所以查询工具是智力的来源。3.2 参数描述是模型的行为指南大模型不像传统程序那样严格按函数签名调用它会把函数名、描述、参数说明当成“使用说明书”。说明书写得越清楚调用越准确。我写完初版之后最大的改进不是代码而是把每个参数的描述重新打磨了一遍。以 transform_object 为例description 我写成“将指定物体按相对偏移移动单位米dx 表示向右为正、dy 表示向上为正、dz 表示向外为正如果你想让物体往左请传负的 dx”。这样一句话比代码里的类型标注有用得多。模型看到后会自己换算正负方向不再出现“想往左却传了正 dx”这种低级错误。颜色参数也要写得死。模型习惯用“红色”这种自然语言但你让它在 JSON 里传一个color: 红后端解析很容易炸。我的做法是在 add_primitive 的 color 参数描述里写明“颜色必须是 6 位十六进制例如 #ff0000 表示红色”并在 server 端做解析兜底把常见的英文颜色名也转成十六进制。3.3 返回值要面向模型而不是面向人工具返回值要同时满足两拨读者模型要拿它做下一步推理用户要能听懂。我的方案是统一返回 JSON 字符串结构固定为{ok: true/false, message: ..., data: {...}}。比如 add_primitive 成功之后返回{ ok: true, message: 已创建立方体 red_cube, data: { id: node_1024, name: red_cube, position: [0.0, 1.0, 0.0], size: 1.0 } }模型拿到ok: true就知道操作成功拿到name就知道后续要引用哪个物体。如果返回错误我会尽可能给出原因比如“物体 table_top 不存在当前场景中的名字为 table_1, table_top_marker”模型看到具体名字后下一轮调用就能自我纠正。4. 动手实现从空目录到可调用4.1 环境准备与项目结构我用的是 Python 3.10 官方 MCP SDK。项目本身很简单不引入重型三维库只用一个内存场景图。python -m venv .venv source .venv/bin/activate pip install mcp[cli] pydantic目录结构大概是3d-mcp-server/ ├── server.py ├── scene.py └── config.jsonserver.py 负责 MCP 工具层scene.py 负责 3D 场景数据结构config.json 记录场景自动保存路径。这样拆分的好处是工具层和数据层不互相依赖你后续完全可以只替换 scene.py把后端换成游戏引擎或商业三维软件的内核。4.2 用官方 SDK 写 server官方 SDK 自带一个 FastMCP 封装类写起来像微服务框架一样简单。核心代码如下import json from mcp.server.fastmcp import FastMCP from scene import SceneGraph scene SceneGraph() mcp FastMCP(3d-world-server) mcp.tool() def add_primitive( name: str cube, primitive: str cube, x: float 0.0, y: float 0.0, z: float 0.0, size: float 1.0, color: str #cccccc, ) - str: 在场景中新增一个基础体。primitive 支持 cube、sphere、cylinder单位为米颜色为 6 位十六进制。 node scene.add_primitive(name, primitive, (x, y, z), size, color) return json.dumps({ok: True, message: f已创建 {primitive} {node.name}, data: node.to_dict()}) mcp.tool() def transform_object(name: str, dx: float 0.0, dy: float 0.0, dz: float 0.0) - str: 将指定物体按相对偏移移动单位米dx 向右为正dy 向上为正dz 向外为正。 if not scene.exists(name): return json.dumps({ok: False, message: f物体 {name} 不存在}) node scene.move(name, dx, dy, dz) return json.dumps({ok: True, message: f{name} 已移动, data: node.to_dict()}) mcp.tool() def query_scene(pattern: str *) - str: 查询场景中所有物体及坐标、包围盒、颜色。pattern 支持通配符匹配物体名。 nodes scene.query(pattern) return json.dumps({ok: True, count: len(nodes), data: [n.to_dict() for n in nodes]}) mcp.tool() def set_material(name: str, color: str , roughness: float 0.5) - str: 修改物体的颜色和粗糙度。color 应为 6 位十六进制。 if not scene.exists(name): return json.dumps({ok: False, message: f物体 {name} 不存在}) node scene.set_material(name, color, roughness) return json.dumps({ok: True, message: f{name} 材质已更新, data: node.to_dict()}) mcp.tool() def delete_object(name: str) - str: 删除场景中的指定物体。 if not scene.exists(name): return json.dumps({ok: False, message: f物体 {name} 不存在}) scene.delete(name) return json.dumps({ok: True, message: f{name} 已删除}) mcp.tool() def export_scene(path: str scene.json) - str: 把当前场景导出为 JSON 文件。 count scene.export(path) return json.dumps({ok: True, message: f已导出 {count} 个物体到 {path}}) if __name__ __main__: mcp.run(transportstdio)这段代码最需要注意的地方是每个函数的 docstring。FastMCP 会自动把函数的签名和 docstring 转成 JSON Schema 发给客户端。你其实是在用“注释”给模型写说明书。4.3 场景内核的几行关键逻辑scene.py 是实现细节但有几个点值得展开。我用了最简单有效的场景图结构一张以物体名为 key 的字典外加一个自增 ID 用来保持唯一性。import re import json class SceneNode: def __init__(self, name, primitive, position, size, color): self.name name self.primitive primitive self.position list(position) self.size size self.color color self.roughness 0.5 self.parent None self.children [] def to_dict(self): return { name: self.name, primitive: self.primitive, position: self.position, size: self.size, color: self.color, roughness: self.roughness, } class SceneGraph: def __init__(self): self.nodes {} self.counter 0 def _normalize_name(self, name): name name.strip().lower() name re.sub(r[\s\-], _, name) while name in self.nodes: self.counter 1 name f{name}_{self.counter} return name def add_primitive(self, name, primitive, position, size, color): node_name self._normalize_name(name) node SceneNode(node_name, primitive, position, size, color) self.nodes[node_name] node return node def exists(self, name): return name in self.nodes def move(self, name, dx, dy, dz): node self.nodes[name] node.position[0] dx node.position[1] dy node.position[2] dz return node def set_material(self, name, color, roughness): node self.nodes[name] if color: node.color color node.roughness roughness return node def delete(self, name): del self.nodes[name] def query(self, pattern): matcher pattern.replace(*, .*) return [node for name, node in self.nodes.items() if re.match(matcher, name)] def export(self, path): payload [node.to_dict() for node in self.nodes.values()] with open(path, w, encodingutf-8) as f: json.dump(payload, f, ensure_asciiFalse, indent2) return len(payload)这个内核虽然简单但足够模拟 AI 操作三维物体的完整闭环。真正接入渲染引擎时只需要把 add_primitive、move 这些方法内部替换成相应引擎的命令。4.4 注册进 AI 客户端并做一次冒烟测试支持 MCP 的 AI 客户端一般都会提供 MCP server 配置入口。配置本质上是告诉客户端“用哪个命令起这个 server”。我的 config.json 是这样写的{ mcpServers: { 3d-world: { command: python, args: [server.py], env: { SCENE_FILE: workspace/scene.json } } } }配置完成后重启客户端它应该能自动拉起 server并通过 tools/list 拿到六个工具。冒烟测试我就问一句话“在场景中心放一个红色立方体。”如果配置正常客户端会调用 add_primitive参数大概是name: red_cube, primitive: cube, x: 0, y: 0, z: 0, size: 1, color: #ff0000。server 返回成功后再问一句“现在场景里有什么”它应该会调用 query_scene并把立方体信息念给你听。走到这一步整个链路就通了。后面的工作基本都是在完善细节。5. 一次完整的自然语言改场景复盘5.1 模型如何拆解任务我拿一个稍微复杂的例子复盘用户说“在桌子上放一个杯子桌子在场景中叫 table_1桌面高度大概是 z1.2 米”。这句描述其实包含多个隐性步骤。模型不能直接把杯子硬编码到某个坐标它需要先明确“杯子应该放在桌子顶面之上”。我预期的理想调用顺序是调用 query_scene搜索 table_1拿到它的位置和包围盒。根据包围盒算出一个桌面中心坐标比如 (0.5, 1.2, 0.3)。调用 add_primitive创建容器或圆柱体位置设在桌面中心上方一点。调用 query_scene 验证场景状态再向用户汇报。如果我的工具没把包围盒信息返回给模型模型就只能瞎猜“杯子的位置”。这也是为什么我在查询工具里特意返回 position 和 size而不是简单说一句“场景中有几个物体”。5.2 返回结果如何影响下一步模型不是一次推理完成所有动作的它需要不断从结果中获取新信息。这里最有意思的是返回错误也能成为下一步行动的依据。有一次我让 AI“把场景里所有红色物体改成蓝色”。它先调用 query_scene发现没有任何物体带 color 属性——因为我初版返回里忘了加颜色字段。于是模型停顿了一下然后直接回答“当前场景中没有可识别的红色物体”。我事后检查确实应该把材质字段提前放到查询结果里。像这种“查询字段不全”的问题传统接口联调时靠人眼发现在 MCP 场景里却会直接表现为 AI 能力缺失。调整后同样是这个问题模型就能先查出color: #ff0000然后逐个调用 set_material。整个流程非常接近一个真人助手的工作方式先看场景再做决定再验证结果。5.3 失败回滚与现场恢复AI 连续调用工具时最怕的是“做了三步第四步失败”场景停在一个半成品状态。比如建好了桌子又建好了杯子结果给杯子贴材质时传了一个不存在的名字。我在 server 里加了一层简单的操作日志每次成功调用都会记录一条原始操作。这样如果用户不满意我还能提供一个undo_last工具来撤销最近一次成功的修改。当然这个工具原本不在最小工具集里后来用着用着你就发现AI 操作场景这件事本身需要容错机制。更稳妥的做法是让场景图支持快照。每次调用前把整个场景序列化到内存里某一步失败就回滚。对小型个人项目来说快照开销可以忽略不计但能避免很多“AI 把场景搞乱”的尴尬瞬间。6. 踩坑实录与问题速查6.1 高频问题表和根因分析我把自己踩过的坑按频率排了个序做成一张速查表。如果你照着做遇到类似问题可以直接对照。问题现象根因解决方案客户端连不上 server启动命令或 Python 环境不对检查 config.json 的 command 是否指向正确解释器工具列表为空装饰器作用域不对或代码有语法错误先手动运行 server.py 看是否报错模型总是不调某个工具description 太模糊模型不知道适用场景重写 description明确“什么时候该用”参数传入“红色”而非“#ff0000”参数描述没写格式要求在字段描述里强制格式并在后端做兼容转换物体名带空格后续找不到模型按自然语言命名入参做 normalize并在返回值里暴露最终名字连续操作后场景状态混乱缺少查询和回滚机制提供 query_scene 和 undo_last最让我意外的是“模型不调工具”这一类。有时候不是代码错了而是模型“觉得”题目太简单不需要调用工具。比如你问“现在场景里有什么”如果之前的对话里已经出现过场景描述模型可能会凭记忆回答而不是老老实实去查。解决办法是在 system prompt 或工具 description 里明确要求“任何关于场景状态的问题都必须先调用 query_scene”。6.2 三个我一直在用的排错技巧第一个技巧是看原始 JSON-RPC 帧。MCP 客户端有时会把模型内部过程吞掉只给你最终回答。我在调试阶段给 server 加了一个环境变量开关MCP_DEBUG1打开后会打印收到的每个 tools/call 请求和返回值。一眼就能看出模型到底传了什么参数、哪个字段多了一个空格。第二个技巧是给工具参数加范围约束。pydantic 支持数值校验但 FastMCP 的 schema 生成对 Field 的支持非常丰富。比如 size 可以约束在 0.01 到 100 之间roughness 约束在 0 到 1 之间。超过范围直接返回参数错误避免修改到一半才发现数值离谱。第三个技巧是做“干跑模式”。我在 transform_object 里加了一个 dry_run 参数。dry_runTrue 时不实际修改场景只计算并返回“修改后会变成什么状态”。模型先干跑一遍确认结果对了再真正执行。这对测试阶段特别有用能显著减少 AI 误操作的次数。7. 安全与权限AI 会不会把场景改废7.1 最小权限与只读查询让 AI 操作三维场景风险往往不在“改坏一个物体”而在“AI 拥有多少系统能力”。MCP server 本质上是一个执行者模型每调用一个 tool你的代码就在真实环境里跑一段逻辑。我坚持的最小权限原则是只暴露场景编辑相关函数绝不暴露文件系统、命令执行、网络请求这类通用能力。如果你确实需要让 AI 导出场景并压缩成 zip不要直接给它一个“执行 shell 命令”的工具而是写一个专门的export_and_archive函数内部固定调用压缩逻辑。宁可多写几个专用工具也不要给一个万能执行入口。7.2 入参校验与场景一致性AI 传参数很少“完全守规矩”校验必须落在后端。我的场景内核里每次操作都会查物体是否存在transform_object 会检查位移量是否是有限数字避免 NaNadd_primitive 会检查 size 是否为正数颜色字符串会做正则校验不符合就转成灰度默认值。还有一致性问题。如果你的场景还要被别的进程读写AI 修改的时候加一把线程锁是必须的。我在 SceneGraph 的操作方法里加了threading.Lock保证 MCP server 在 SSE 模式下多个请求并发时不会出现两个工具同时改同一物体的情况。7.3 审计与快照机制最后是最容易被人忽略的审计。MCP 的调用链很长用户可能连问了五次才得到一个满意结果中间 AI 可能误删了三次物体。没有记录你根本说不清“现在这个场景是怎么变成这样的”。我的做法是每次成功调用都往一个audit.log里写一行时间、工具名、参数 JSON、返回状态。这个日志不会给模型看只给开发者排查用。配合场景快照我可以在任何一轮调用之后恢复现场。个人项目这么做多少有点“过度”但如果你要做团队工具审计能力早晚要补。8. 扩展思路还能往哪里走8.1 从单机场景到数字孪生我的 MCP server 目前跑在本地操作的是一个内存场景图。同样的逻辑可以平滑迁移到数字孪生场景场景内核换成实时业务数据物体坐标对应真实设备位置查询工具对应设备状态上报。到那时候AI 的一句“把三号设备的状态调整为运行”就会映射成一次真实的控制操作而不是虚拟物体的位置变更。当然权限管理要更严格。数字孪生里的写操作影响现实设备至少应该增加“确认机制”或“二次审批工具”让 AI 不能直接完成破坏性动作。8.2 让场景查询支持语义检索我最近在折腾的另一件事是给 query_scene 增加语义检索能力。现在查询只能按物体名匹配通配符AI 问“这里有靠窗的物体吗”时它要先知道窗的定义和物体的位置关系才能算出来。如果场景里已经有一面墙的坐标我完全可以在查询工具里内置几何规则返回所有位于某个平面附近、或处于某个包围盒内的物体。这是一个很自然的进化方向。MCP 工具不一定要保持“最小功能”当你信任场景数据模型之后可以把更聪明的空间计算包进工具内部让 AI 的表达更接近自然语言而不是被迫说出“pos_x 在 3 到 5 之间”这种反直觉的话。最后再分享一个小习惯我不管改了什么逻辑第一件事永远是跑一遍“冒烟对话”让 AI 从空场景建一个物体、查询一次、移动一次、删掉一次。这套回归流程不到一分钟但能拦住百分之八十的接口破坏。做 MCP server 这种事边界清晰比功能丰富重要得多把查询做扎实了后面的路自然就顺了。