MCP实战:让AI自主查询CRM中的3D场景数据
“MCP”这个词最近在 AI 工程圈里热度一直不减但很多人的理解还停留在“给 AI 连一个工具”的层面。我这次做的活儿不太一样要把一套虚拟世界 CRM 里的全部 3D 数据通过 MCP 协议变成 AI 可以直接查、直接算、直接答的问题入口——最终还搭了一个可直接调用的演示站。整个链路从零开始打通中间踩过的坑、做过的取舍我写在这篇文章里给准备做 AI 与业务数据连通的读者当个参考。文章不会贴一套“标准答案”因为我也不觉得自己这套设计是什么行业标准。它就是一次非常具体的工程实践目标是“让 AI 能自主地读取 3D 场景里的资产信息”主干是用 MCP Server 封装数据工具前端再套一个可视化演示站。你如果手头也有类似“AI 想访问某个业务系统”的活儿这篇应该能给你不少可以直接抄的细节。1. 为什么要给 AI 打通一条访问 CRM 3D 数据的专用通道1.1 传统接口方式到底缺了什么在没有 MCP 之前想让 AI 模型访问一套 CRM 里的 3D 数据通常只有两条路要么写一堆 REST API 给模型调用要么定期导数据、做离线快照喂给模型。两条路我都试过问题很明显。REST API 这条路接口文档和模型是“断联”的。你得在提示词里把接口地址、请求参数、返回示例全部塞给模型模型才能按照你的要求去拼请求。但只要接口字段一变、返回结构一调整提示词就得跟着改一遍。更麻烦的是模型并不知道你到底暴露了哪些接口只能靠你写多少它用多少新增一个查询维度就得重新调一次提示词。离线快照这条路更糟糕。3D 数据最大的特点是带有空间位置、场景层级、可见性这些强语义信息导成 CSV 或者 JSON 快照之后模型确实能“看到”数据但它不知道“最近的可交互 NPC”怎么查“某个包围盒里的资产有哪些”怎么算。它只会做文本层面的匹配不会做空间语义的理解。换句话说数据被动地躺在那里但 AI 没有能“操作数据”的抓手。MCP 引入了一个完全不同的思路把“工具的能力描述”和“工具的调用规范”一起交给模型。模型先通过tools/list发现有哪些工具再根据工具描述决定要不要调用、传什么参数最终通过tools/call拿结果。这一步看似只是协议标准化实际上把“接口与模型的适配成本”彻底从提示词工程里剥离出来了。新增工具就是多注册一个函数模型自动就能感知到不用再改任何提示词。1.2 3D 数据和普通业务数据差在哪为什么值得专门打通普通 CRM 数据比如客户姓名、成交金额、跟进状态本质上是二维的、无空间语义的行记录。3D 数据完全不是一回事。我们访问的 3D 数据至少包含四层信息空间位置每个资产在虚拟世界里有 x、y、z 坐标甚至还有旋转和缩放。场景层级世界包含场景场景包含资产资产下面可能还有子节点这是树形结构。模型资源引用资产的几何体并不在 CRM 数据库里而是存放在对象存储上的模型文件glb、fbx 之类数据库里只有路径和版本号。运行时状态一个 NPC 或道具是否可见、是否能交互、当前属于哪个副本这些状态是动态的经常变。想把这类数据真正“喂”给 AI光把数据库表结构暴露出去是没用的。模型需要一个“空间语义的翻译层”把坐标、范围、可见性这些概念变成它可理解的操作。比如人类说“把场景 A 里的可交互 NPC 按距离排个序”模型要完成这件事必须先知道场景 A 的 ID、NPC 的类型标签、距离计算的参照坐标、以及返回结果的排序字段。这些东西没法靠猜必须由工具显式提供。MCP 的价值就在这里它不只是传输协议还是工具能力的描述协议。你可以在工具的 schema 里把“坐标用什么单位、距离怎么算、标签怎么过滤”写得明明白白模型会按描述去调用而不是靠猜。这点对 3D 数据尤其重要因为空间操作对参数精度很敏感。2. 创世虚拟世界CRM的3D数据到底长什么样2.1 核心表结构和关键字段要设计 MCP 工具先得把数据基础摸清。我这里就把“创世虚拟世界CRM”下文简称创世 CRM里 3D 相关的数据模型简化成一个可讨论的版本方便复盘。实际系统里字段更多但核心骨架基本是这么几张表。世界表world字段类型说明world_idstring世界唯一标识namestring世界名称statusstring上线中/维护中/下线coordinate_systemstring坐标系类型如米制或格子制created_atdatetime创建时间场景表scene字段类型说明scene_idstring场景唯一标识world_idstring所属世界 IDnamestring场景名称boundsjson场景的 AABB 包围盒记录 min/max 坐标asset_countint场景内资产数量资产表asset字段类型说明asset_idstring资产唯一标识scene_idstring所属场景 IDasset_typestring资产类型NPC/道具/装饰/建筑等model_pathstring模型文件在对象存储中的路径positionjson坐标 {x, y, z}rotationjson旋转 {x, y, z, w}scalejson缩放 {x, y, z}visibilitybool当前是否可见interactablebool是否可交互tagsarray标签列表versionstring数据版本号这三张表基本覆盖了“世界-场景-资产”的层级关系。字符表实际上和资产表比较像只是多了一些角色特有字段在工具设计时我用“asset_type NPC”来统一处理并不单独建一张角色表。2.2 模型文件不进入 MCP进来的只有元数据一个很容易踩的误区以为“通过 MCP 访问 3D 数据”就是把 3D 模型文件直接喂给 AI。完全不是。模型文件glb、fbx 动辄几十上百 MB根本不适合放进 MCP 的工具返回里AI 模型拿不到、也不需要。我们通过 MCP 暴露的是资产的元数据ID、类型、坐标、旋转、可见性、交互状态、标签、版本。这些数据体积小、语义强模型拿到之后能判断“该查什么、该算什么、下一步该怎么操作”。真正需要看模型文件的时候工具返回里给出model_path就够了由下游渲染管线去对象存储拉文件。所以大家看着 MCP 里流动的是轻量 JSON背后连接的却是一套完整的 3D 数据资产这个设计要让 MCP 只做“语义层”的工作不做“渲染层”的工作。数据量方面我按实际体量做了个假设30 个左右的世界、几百个场景、单场景几千到几万个不等的资产、总资产量大概在百万量级。这个量级决定了 MCP 工具必须支持分页、必须支持按条件过滤、必须在返回前裁剪字段否则任何一次全量查询都是上下文灾难。3. MCP Server 的骨架协议选型与核心设计3.1 选型为什么用官方 Python SDKMCP 官方提供了 Python 和 TypeScript 两套 SDK我选了 Python。原因很实际创世 CRM 内部的数据网关本身就有 Python 服务3D 资产检索逻辑也有一半躺在 Python 里用 Python 写 MCP Server 意味着可以直接复用内部库而不需要再开一套 Node 链路。安装很简单pip install mcpmcp包装完之后最推荐直接使用FastMCP这类高层封装它把服务初始化、工具注册、schema 自动生成都处理掉了代码量很短。你如果想高度定制传输层、消息格式、认证逻辑再考虑用底层 Server API。我个人强烈建议新项目从FastMCP开始。它不会屏蔽掉关键机制反而会让你更清楚“工具描述”和“工具实现”之间那层映射关系。等跑通了再去看底层实现理解成本低很多。3.2 架构设计MCP Server 只是一层薄封装这是整篇文章里我最想强调的设计MCP Server 不应该直接连数据库它应该只是一层很薄的协议适配层。我当时的做法是让 MCP Server 转发请求到创世 CRM 已有的内部数据网关。完整链路是这样AI 客户端 - MCP Host - MCP Server - 内部数据网关 - CRM 数据库 / 对象存储这样做有三个好处。第一鉴权可以统一。内部网关上已经有一套基于服务令牌的鉴权机制MCP Server 只需在启动时从环境变量读取令牌所有下游调用都走网关不用为 MCP 单独再造一套权限体系。第二审计更省事。谁在什么时间调了什么工具、查了哪些数据在网关层全部有日志。我们甚至不用改 MCP 代码就能把 AI 的每一次访问纳入已有的审计系统这在企业内部系统里是刚需。第三后续替换成本低。如果哪天 MCP 协议本身出了更好的替代方案或者团队决定换一套实现只需要重写薄薄一层适配层背后的数据网关和业务逻辑完全不受影响。当然如果是一个很小的演示项目没有网关也没有历史系统直接让 MCP Server 查数据库也没问题。但只要你面对的是真实业务系统多一层网关就是你给自己留的一条退路。3.3 最小可用的 Server 代码长什么样我用官方 Python SDK 的FastMCP写了一个最小骨架大概长这样from mcp.server.fastmcp import FastMCP mcp FastMCP(world-crm-3d) mcp.tool() def query_assets( scene_id: str, asset_type: str | None None, page: int 1, page_size: int 50 ) - dict: 查询指定场景下的3D资产列表支持按资产类型过滤。返回轻量字段asset_id,name,asset_type,position,visibility,interactable。 return crm_gateway.query_assets( scene_idscene_id, asset_typeasset_type, pagepage, page_sizepage_size ) mcp.tool() def get_asset(asset_id: str) - dict: 获取单个3D资产的完整元数据包含坐标、旋转、缩放、可见性、交互状态、标签和版本号。 return crm_gateway.get_asset(asset_idasset_id) if __name__ __main__: mcp.run(transportstdio)FastMCP会根据函数的类型注解和文档字符串自动生成tools/list时返回的 JSON Schema。也就是说你不需要手写 schema但文档字符串本身会变成模型选择工具的依据这也是为什么我在这篇文章里反复强调“描述要写清楚”——它不是给人看的注释而是给模型看的说明书。如果你要支持远程调用可以把mcp.run(transportstreamable-http)这样 MCP Server 就能以 HTTP 方式暴露多个客户端也能共享同一个服务实例。本地调试、单客户端场景用stdio更省事不用开端口。4. 工具集设计让 AI 能“看懂”3D数据的五个工具4.1 工具设计总原则少而精描述优先MCP 工具设计有一个很微妙的地方工具太多会让模型在tools/list里挑花眼选择错误率上升工具太少又会覆盖不了真实需求。我的经验是第一版控制在 58 个先把最核心的查询类能力打通跑起来之后再按模型实际调用日志去迭代。工具命名也要“面向模型”。list_worlds、query_assets、find_nearby这种动词对象的结构模型一眼就能看懂。千万别起什么data_fetch_v2、get_info这种名字模型根本猜不到它是干嘛的。你是在给模型设计“产品”不是在给后端工程师设计“接口”这两者的命名哲学完全不同。还有一个容易被忽略的点工具返回的字段也要做裁剪。模型上下文窗口是有限资源一次工具调用返回几十个字段里面一大半模型根本用不上只会白白消耗 token还会干扰模型的判断。返回里只放能支撑下一步决策的字段其他信息要么藏到get_asset这种详情工具里要么直接丢弃。4.2 五个工具逐个拆解我在这个项目里最终保留了五个工具覆盖了“世界浏览 → 场景浏览 → 资产查询 → 资产详情 → 空间检索”这条完整的认知链路。工具 1list_worldsmcp.tool() def list_worlds() - list[dict]: 列出当前账号可访问的所有虚拟世界。返回每个世界的 id、名称和状态。 return crm_gateway.list_worlds()这个工具没有参数负责把模型的“世界是什么”锚定住。没有它模型都不知道自己能在哪些世界里做检索。工具 2list_scenes(world_id)mcp.tool() def list_scenes(world_id: str) - list[dict]: 根据世界 ID 列出该世界下所有场景。返回场景 id、名称、资产数量。 return crm_gateway.list_scenes(world_idworld_id)这是把树形结构逐层展开的过程。模型先选世界再选场景接下来才能精准定位到资产。工具 3query_assets(scene_id, asset_type, tags, page, page_size)mcp.tool() def query_assets( scene_id: str, asset_type: str | None None, tags: list[str] | None None, page: int 1, page_size: int 50 ) - dict: 查询场景内的 3D 资产列表支持按资产类型和标签过滤。asset_type 可取值NPC, Prop, Decor, Building。返回分页后的资产摘要。 return crm_gateway.query_assets( scene_idscene_id, asset_typeasset_type, tagstags, pagepage, page_sizepage_size )这是最核心的查询工具。我没有让它返回全字段而是返回“摘要字段”比如 asset_id、name、asset_type、position、visibility、interactable那些 model_path、rotation、scale 之类在列表场景里用不到被裁剪掉了。工具 4get_asset(asset_id)mcp.tool() def get_asset(asset_id: str) - dict: 获取单个 3D 资产的完整元数据包含坐标、旋转、缩放、可见性、交互状态、标签、版本号、模型文件路径。 return crm_gateway.get_asset(asset_idasset_id)这是细粒度入口。模型在列表里看到一个资产感兴趣就会调这个工具拿全量信息。工具 5find_nearby(world_id, x, y, z, radius, asset_type, limit)mcp.tool() def find_nearby( world_id: str, x: float, y: float, z: float, radius: float, asset_type: str | None None, limit: int 20 ) - list[dict]: 以坐标 (x,y,z) 为中心在世界内查找半径 radius 米范围内的 3D 资产。坐标单位是米。返回资产按距离升序排列最多返回 limit 条。 return crm_gateway.find_nearby( world_idworld_id, xx, yy, zz, radiusradius, asset_typeasset_type, limitlimit )这最后一个工具是我特别坚持加的。3D 数据的灵魂是空间关系没有空间检索的 MCP 工具集只能叫“数据库查询器”不配叫“3D 数据访问层”。有了find_nearby模型才能真正回答“离这个点最近的门店 NPC 是谁”这种问题。4.3 schema 描述怎么写才能减少调用失败JSON Schema 是 MCP 工具的“门面”模型看不到你的 Python 代码它只能看到tools/list返回的 schema。这个 schema 写得越差模型调用失败率越高。我当时总结了一套实用的写法参数说明必须包含单位。比如find_nearby的 radius如果我不写“单位是米”模型可能传 100以为是厘米服务端又会把 100 当成 100 米处理结果完全不可用。字段取值必须枚举。比如asset_type我会在描述里明确列出“NPC, Prop, Decor, Building”。模型看到合法枚举值之后传参的随意性会大幅下降。返回结构必须给示例。query_assets的描述里我会支付一句“返回字段仅包含 asset_id, name, asset_type, position, visibility, interactable”让模型提前知道它接下来能拿到什么避免它去猜并不存在的字段。参数拆到最细。坐标 x、y、z 一定要拆成三个独立字段每个都是 number 类型不要在 schema 里写一个 “position: array”。模型对数组的组装方式经常不符合你的预期拆细之后基本不会出错。提示这类 schema 优化可以在跑了真实调用日志后持续迭代。模型传错的参数就是最好的一手反馈。5. 演示站的落地从 MCP Server 到 Web 可视化调用的完整链路5.1 演示站的定位与整体链路MCP Server 本身跑通了但只有命令行工具没法给别人看效果。于是我又搭了一个可直接调用的演示站目的有三个让人能通过界面直观看到 3D 数据长什么样、让非技术人员也能体验自然语言查 3D 场景、把每次工具调用的输入输出可视化出来方便调试。演示站的架构是这样的浏览器前端 - FastAPI 后端 - MCP Client - MCP Server - 内部数据网关这里有一个很多人会绕晕的点前端为什么不能直接调 MCP Server因为 MCP 用的是 stdio 或 streamable-http 传输浏览器端的 JavaScript 不方便直接建立 MCP 会话。而且从架构上看前端直接连 MCP Server 会绕过鉴权、绕过审计、绕过所有你在网关上做的安全措施太危险了。正确的做法是让后端充当 MCP Client再把结果以普通 HTTP JSON 返回给前端。前端的体验是一个标准的 Web 应用MCP 的复杂度完全被挡在后端。5.2 用 FastAPI 包一个 MCP Client 代理层后端我用 FastAPI逻辑很简单每个接口内部创建一个 MCP Client 会话调用对应工具把结构返回给前端。from fastapi import FastAPI from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client app FastAPI() async def call_tool(tool_name: str, arguments: dict): server_params StdioServerParameters( commandpython, args[/srv/mcp/world_crm_mcp_server.py], env{CRM_GATEWAY_URL: http://crm-gateway:8000, CRM_GATEWAY_TOKEN: xxx} ) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() result await session.call_tool(tool_name, arguments) return result.content app.get(/api/worlds) async def api_list_worlds(): return await call_tool(list_worlds, {}) app.get(/api/scenes/{world_id}) async def api_list_scenes(world_id: str): return await call_tool(list_scenes, {world_id: world_id}) app.get(/api/assets) async def api_query_assets(scene_id: str, asset_type: str | None None, page: int 1, page_size: int 50): return await call_tool(query_assets, { scene_id: scene_id, asset_type: asset_type, page: page, page_size: page_size })注意我的call_tool每次都是“临时起一个子进程跑 MCP Server”。这个方案在演示站没问题因为访问量小、每个请求的生命周期短如果要做成高并发生产服务就应该常驻一个 MCP Client 连接用连接池管理而不是每次请求都重新拉起子进程。但演示站阶段简单稳定优先。5.3 前端坐标可视化与操作面板前端我没有用什么重型 3D Web 框架只用了一个 Canvas 二维投影来展示资产坐标。这样加载快、代码少、演示效果足够直观。核心思路很朴素后端/api/assets返回的资产列表里有position字段前端把场景的 bounds 映射到 Canvas 坐标然后逐点绘制。鼠标悬停到某个点上就调用/api/assets/{asset_id}显示详情面板。页面操作面板分成四块世界选择器加载/api/worlds下拉选择世界。场景列表加载/api/scenes/{world_id}点击场景后加载该场景资产。资产画布Canvas 上绘制所有资产的平面投影不同颜色代表不同资产类型点选查看详情。JSON 返回面板显示每一次后端调用的完整返回结果方便技术同事排查问题。前端整体就一个 HTML 文件加一个 JS 文件没有构建工具。演示站嘛能干活、能说清楚链路就足够了非要上 React/Vue 反而增加部署成本。5.4 跑通后的效果自然语言直接查 3D 场景演示站跑通之后我把同样一套 MCP Server 接到支持 MCP 的模型客户端里做了个很“显摆”的验证。我在输入框里问了一句“把场景 A 里所有可交互的 NPC 按距离从最近到最远排个序参照点是主城广场中心。”模型实际执行的过程是调list_worlds确认世界 ID调list_scenes定位场景 A调query_assets过滤asset_typeNPC、interactabletrue获取资产列表及坐标在上下文里计算每个 NPC 与参照点的距离排序后返回自然语言结果。全程我一句话都没提接口路径模型是靠工具描述自己“导航”过去的。这种自主能力在传统 REST API 模式下根本不可能实现因为模型根本不知道你有哪些接口。演示站的意义就是把这条链路可视化模型在多轮上下文里自主规划了“遍历世界 → 找场景 → 查资产 → 算距离”的完整步骤每一步的输入输出都在面板上清清楚楚。6. 我把三类坑踩平了你们可以直接绕开6.1 坑一工具返回太大AI 上下文直接爆炸我第一次把query_assets写出来时返回的是全量字段而且没有强制分页。结果模型调用一次一个场景 5000 条资产全部塞了回来单次工具响应大概 300KB 的 JSON。当时模型客户端直接卡住稍等片刻后报“上下文长度超限”。排查过程其实很简单我先是看模型客户端的日志发现工具返回被截断再去网关看tools/call的实际响应才意识到是query_assets的返回体太大了。根因就是设计时没把“模型上下文是稀缺资源”当回事。解决方案分三层强制分页page_size上限锁死到 100模型要更多数据得翻页。裁剪字段列表接口只返回摘要字段详情走单独的get_asset。压缩坐标精度坐标从双精度浮点统一保留到小数点后 2 位对空间计算完全够用体积能小一小截。后来我给自己定了个硬规矩单次工具返回尽量控制在 1KB 到 2KB 以内。超过这个范围的要么分页要么裁剪要么拆成新工具。这个红线帮我在后续设计里避免了很多次“能跑但跑不动”的问题。6.2 坑二schema 描述太模糊AI 反复传错参数find_nearby刚上线时模型调用成功率只有六成左右。我翻失败日志发现模型经常把坐标传成数组{position: [100, 200, 50]}而不是{x: 100, y: 200, z: 50}。一开始我骂模型“笨”后来打开tools/list返回的 schema 一看发现自己写的坐标参数只有一个position字段类型还是array描述也没写单位。模型根本不知道数组里三个位置代表什么更不知道单位是多少能传对才怪。修复方案是重写 schema把 x、y、z 拆成三个独立 number 字段每个字段的 description 都写明“坐标值单位米”并在工具描述里补充“以场景世界坐标系为准”。改完之后模型调用的参数规范性明显提升成功率从六成涨到九成以上。这个坑给我的教训很直接MCP 工具的参数设计不能按后端习惯来要按“最笨用户”的认知来。模型调用链路里没有人为兜底任何一个语义含糊的字段都会变成真实的调用失败。6.3 坑三SDK 版本漂移本地能跑部署就挂当时我本地调试用的是最新版 MCP Python SDK跑得挺好。部署到演示站服务器时直接pip install mcp拉了一个当时的最新版本结果 Client 端连接 Server 的时候报了一串握手错误。排查链路是这样的先看 FastAPI 的异常日志发现 ClientSession 在initialize()阶段超时再手动在服务器上跑 MCP Server用命令行工具直接连接发现能通最后比对本地和服务器两个环境的 SDK 版本才发现两者差了两个小版本而 Client 和 Server 的握手逻辑在这个区间里发生了不兼容的变化。解决方式很老套但非常有效锁版本。把本地和服务器的mcp包统一锁定到同一个版本并在项目里放一个requirements.txt写明mcp1.x.x。同时部署脚本里加了一步启动前自检用mcp --version打印版本号确保环境跑的是预期版本。注意MCP 生态还在高速迭代期跨版本不兼容是常态。凡是跑通了的环境第一件事就是把 SDK 版本锁死别给未来留漂移空间。6.4 反过来想什么时候根本不该用 MCPMCP 不是万能胶。我做完这个项目后回头看有些场景真的不该硬上。如果你的需求是“AI 只调一个固定的、调用路径不变的接口”比如每天定时拉一次数据、参数永远一样那直接写一个 REST API 让模型调用甚至直接在提示词里给数据都比上 MCP 更简单。MCP 的收益在“模型自主发现工具 多个工具组合 协议标准化”这三件事上如果你的场景不依赖这三件事引入 MCP 只会增加一层不必要的复杂度。还有一类场景要谨慎对延迟极其敏感的服务。MCP 的每次工具调用都要经过协议封装、schema 解析、会话管理比裸 HTTP 调用多一层开销。如果业务要求 50 毫秒内返回那 MCP 这一层可能就会成为瓶颈这种时候老老实实用专用接口更靠谱。最后说一个很多人忽略的边界MCP 工具一旦暴露模型是可以自主决定“什么时候调、调哪个”的。所以写库类、删除类、变更类的操作要么第一版坚决不开放要么独立成一个带额外鉴权的“管理域工具集”和查询类工具彻底隔离。我这个演示站只开放了查询类工具所有写操作都不在 MCP 边界内。一个小习惯调用审计别等上线后再补这个习惯其实是在踩了“模型乱调工具”的坑之后养成的。一开始我觉得反正只有查询类工具模型爱调就调能调出花来不成。后来发现模型有时会重复调用同一个查询十几次纯粹因为它的历史上下文里弄丢了前面的结果。这种事对数据安全没影响但很浪费网关资源数据成本哗哗涨。所以我在演示站后端加了一张工具调用审计表记录了每一次tools/call的工具名、入参、出参大小、耗时、调用来源。这个小动作之后模型频繁重复调用的问题立刻暴露出来了。后来我在工具描述里加了一句“如果你已经拿到目标资产列表就不要重复调用相同参数的 query_assets”才把这个行为压下去。如果你也要做类似的项目我强烈建议审计日志从第一天就加上。MCP 工具调用毕竟是由模型自主发起的你根本预判不了它会怎么用你的工具有了日志才知道它到底是怎么用的。这个习惯花不了多少时间但后面能帮你省下非常多排障的时间。