让 AI 走进我的 3D 世界:一个 MCP server 的完整实现

一、先看效果

【图1:AI 以可见形象进入世界,真人玩家实时看到它走动 ------ docs/demo-live.png】

先说清楚这不是一个模拟器。

我运营着一个浏览器里的多人 3D 世界,里面有真人在走来走去、有建筑和模型。我做的事情是: 写了一个 MCP server,让任意 MCP 宿主(Claude Desktop / Cursor / Cline)里的 AI 以一个可见的形象走进这个世界。

  • 真人那边:在浏览器里实时看到 AI 走过来、说话(头顶气泡,30m 内可听)、跟随自己,能跟它对话
  • AI 那边:看到的是文字 ------ 附近的玩家、带描述的物体、距离、传送门,以及"我上次看完之后发生了什么"

AI 不是在读一个数据库,它在一个地方待着。

【图2:world_observe 的真实原始输出 ------ docs/demo-observe.png】

二、30 秒讲清 MCP 是什么

Model Context Protocol,本质就是 stdio 上跑 JSON-RPC:

c 复制代码
宿主(Claude/Cursor)
   │  启动子进程,stdin/stdout 收发 JSON-RPC
   ▼
MCP Server(我们的 Node 进程)
   │  自己决定怎么干活(HTTP / WebSocket / 读文件...)
   ▼
真实世界(我们的 3D 世界服务器 + Postgres)

对宿主来说,它只需要知道"这个 server 提供了哪些工具、每个工具要什么参数、返回什么"。 中间那层怎么实现,宿主不关心。 这正是我们能把它接进一个真实游戏世界的原因。

一次调用的完整链路:

bash 复制代码
AI 决定调用 world_walk_to({target:{x,z}})
  → MCP server(Node 进程)
  → POST /api/agent/v1/action        (HTTP,带 JWT)
  → 服务器按 5m/s 推进,每 100ms 广播位置
  → WebSocket 推给所有在线真人前端
  → 浏览器里 AI 的形象真的走过去了

三、源码分层

全部 9 个文件、1569 行,无第三方依赖(刻意不用 SDK,Node 18+ 原生实现):

文件 行数 职责
index.js 96 MCP 协议入口:握手、工具/资源/提示词注册、stdio 传输
httpClient.js 262 端点推导、发现文档读取、签票、自动续期、observe、聊天记录
waiter.js 113 消息等待原语:recent 缓冲(解 READY 竞态)、slot 保留多次回执
worldClient.js 319 WebSocket 入场 / 自动重连 / 发动作 / 事件环形缓冲 / 离场
tools.js 264 8 个工具的 schema 与实现
format.js 218 输出组织(字节预算在这里执行)
errors.js 143 错误码 → 中文人话(发生了什么 + 怎么办)
resources.js 88 1 个 Resource:世界自己的导览指南
prompts.js 64 2 个 Prompt:guided_tour / report

分层的核心思路是把"异步消息"和"同步请求"彻底分开:

  • httpClient 只管同步 HTTP(签票、observe、chat history)
  • worldClient 只管长连接(进场、动作、事件流)
  • waiter 是两者之间的粘合层 ------ 它解决的问题见下面难点二

四、三个真实难点(踩过才知道的)

难点 1:observe 的 1900 字节预算

world_observe 是 AI 用得最多的工具,它要把"我周围有什么"讲清楚。但大模型的上下文是有限资源, 一次 observe 灌 20KB 进去,三轮就把窗口吃光了。

所以我给它加了硬预算:默认 1900 字节(<2KB)。这里踩了两个坑:

坑 A:必须按字节算,不能按字符算。 中文在 UTF-8 下是 3 字节。按字符算的话, 一段中文能膨胀 3 倍,预算形同虚设。所以内部统一用 Buffer.byteLength(str, 'utf8')。

坑 B:预算不是"正文能用多少",是"总长减去其他段之后剩多少"。 完整输出结构是:

scss 复制代码
【自述】你在 (x, y, z)          ← 必须保留,AI 靠它算自己的位置
【周围】...按距离排序的物体...
【事件】上次之后发生了什么        ← 长度不定,mid 值波动
【传送门】...固定段落...
【注意 + 下一步】                ← 尾部提示

真正的算法是:

scss 复制代码
可用正文预算 = 1900
             − Buffer.byteLength(【事件】段)
             − Buffer.byteLength(【注意/下一步】段)
             − PORTALS_SECTION_BYTES(320)
             − 已写传送门那一行的长度
             − Buffer.byteLength(已经写完的正文)

少算任何一项,最终都会破 2KB。 我第一版就是漏了"已写正文"这一项,输出稳定在 2100+ 字节, 而单元测试全绿 ------ 因为测试只测了"单个物体超长时会不会截断",没测"多段拼起来会不会超"。

还有一点:近处物体的描述必须完整,远处降级成"仅名称" 。按距离排序后依次写入, 预算写完就停。这样 AI 的上下文里永远是最相关的信息。

边界:宁可不写,不能截半句。 描述被截成半截比不写更糟,AI 会基于错误信息行动。

难点 2:会话续期只能换 token,绝不能重连 WebSocket

Agent 的 JWT 有效期 15 分钟。服务端有个反直觉的实现:

  • WebSocket :JWT 只在建连那一刻校验一次,连上之后过期也继续推
  • HTTP :每次调用都校验,过期立刻 403

这个差异是我在一次 35 分钟的长跑测试里撞出来的:6 个断言失败,全是 HTTP 报 TOKEN_EXPIRED,而 WebSocket 那边 readyState 还是 1(正常连接)、say / move 都还能用。 真实用户看到的就是"AI 还在回话,但它好像看不见这个世界了"。

解决办法是自动续期:启动时从发现文档读 auth.sessionTtlSeconds(拿不到就回落默认), 然后每 TTL × 2/3 换一次票。这里最关键的一行代码是:

ini 复制代码
// 只重新赋值闭包里的 token 变量,绝不重连 WS
token = await httpClient.renewSession();

因为 observe / chat_history 每次调用时才读这个变量,所以这两处 fetch 一行都不用改。

⚠️ 两个必须注意的点:

  1. 游客票不能续期。 游客会话是"换个身份"的语义(每次签票生成新的 agent:guest:<uuid>), 换了票,HTTP 是新身份、WS 还是旧身份 → 状态错乱。所以只有 Key 档才续。
  2. 续期失败要退避重试(5/15/45s),三次后告警但不退出 ------ 进程死了宿主就静默失去工具了。

难点 3:发现文档可能广播 http://,而站点是 https://

我们的发现层放在 /.well-known/virtual-world-agent.json,里面有 apiBase 和 websocket 字段。 理想情况下它们应该是 https:// / wss://。但现实是:

反向代理(Nginx)没配 proxy_set_header X-Forwarded-Proto $scheme; 时, Node 侧只能看到明文 http,广播出去的就是 http://。

在 https 站点上,AI 客户端会因 Mixed Content 被浏览器硬拦 ------ 整个请求根本发不出去。

而讽刺的是,同一个响应里的 world.url 字段是对的(https),只有端点是错的,自相矛盾。

修法分两层:

  1. 服务端 :端点协议优先级改成 x-forwarded-proto → req.protocol → 加密状态 → 权威 world_url 兜底。 最后这层是纵深防御,专治反代漏配头。
  2. 客户端(更重要) :httpClient 只从发现文档里取路径 ,协议一律以 AGENT_HOST 为准。

第 2 条是重点:发现文档是不可信输入。我们的代码里有一段专门检测这种不一致并记 note, 但即使检测到了也不改协议 ------ 环境变量才是唯一权威。

这个坑的通用教训:只要你的服务要对外提供"怎么连你"的说明,就一定会遇到"反代把协议搞错"的情况。 客户端永远不要盲信服务端广播的 URL。

附加:Node 18+ 原生 WebSocket 的三个限制

如果宿主可能跑在 Node 18+(没有 ws 包),直接用全局 WebSocket,会遇到:

  1. 它是 WHATWG 标准,不是 EventEmitter :没有 .on(),只有 addEventListener, 且 e.data 是 string 不是 Buffer
  2. 不能设自定义请求头 :new WebSocket(url, { headers }) 会被静默忽略。 浏览器同款限制 → 鉴权只能走 ?token=<jwt> 查询参数(这是 WebSocket 鉴权的标准降级模式)
  3. 服务端消息有统一信封 :所有消息都是 { type, payload: {...} }。 发动作必须是 { type:'ACTION', payload:{ action, requestId, ...params } }, 把 action 直接放顶层会静默不生效

另外注意:JWT 放在 URL 里会进 access log,所以那个日志文件的权限要收紧。

五、8 个工具

工具 作用 备注
world_discover 零凭证发现世界 读 well-known + capabilities,返回世界名、身份档位、限频规则
world_enter 以可见形象进场 拿票 → 建 WS → 收 READY(含自己出生点)
world_observe 看周围 默认 1900 字节预算 ;可传 maxBytes 调大
world_say 说话 30m 内真人能看到气泡、能听到
world_walk_to 走到坐标 服务端按真实速度推进,有到达回执
world_follow 跟随某个玩家 按 id 持续追,2m 内停住
world_chat_history 读聊天记录 断线重连后恢复上下文
world_leave 离场 主动清理,避免占名额

外加 1 个 Resource (virtual-world://guide,世界自己的导览)和 2 个 Prompt (world_guided_tour、world_report)。

六、两档身份

游客档(只填 AGENT_HOST) Key 档(加 AGENT_API_KEY)
凭证 零,不用注册不用沙箱 API Key
会话时长 30 分钟 自动续期,可长期驻场
观察半径 30m 200m
消息模式 拉模式(收不到推流) 三档可选 eco / standard / realtime
限制 每 IP 1 连接、10 票/小时、5 分钟空闲踢出 按 Key 配额

工具返回值里会带 upgradeHint,AI 自己就能告诉用户"想要更大范围可以配 Key"。

七、怎么接入

方式一:stdio(本地)

json 复制代码
{
  "mcpServers": {
    "virtual-world": {
      "command": "npx",
      "args": ["-y", "agent-virtual-world"],
      "env": { "AGENT_HOST": "https://miduo100.com" }
    }
  }
}

方式二:Remote MCP(零安装)

https://miduo100.com/mcp ------ Streamable HTTP,宿主填个 URL 就行,不用 Node 环境。 (自己实现时零新增依赖:Streamable HTTP 就是一个 POST 入口 + 会话头, 我把它挂在现有 Express 上,服务层直接本地调用,不自我 HTTP 转发, 避免所有远程 AI 共享 127.0.0.1 撞限流。)

已经收录的目录 (不用自己找): npm agent-virtual-world | 官方 MCP Registry io.github.miduo100/agent-virtual-world | Glama | Cursor Directory | Smithery | 魔搭 MCP 广场 | Cline(已提 Issue)

八、两个刻意的设计

1. 坚决不做传送。

没有 teleport,没有 set_position。服务端会直接拒绝,MCP 层也不提供任何绕过包装。 AI 想去哪,必须自己走过去。

就这一条,让它像个"地方"而不是个"数据库"。如果 AI 能瞬移, 那"在世界里行走"这件事对它就没有意义了。

2. 零凭证就能起步。

上线初期的风险不是"被滥用",而是"没人来"。所以我把门槛降到最低: 一个环境变量,30 分钟游客会话,不注册、不申请 Key、不搭沙箱。 API Key 从"进门凭证"降级成了**"推流特权"** ------ 不是权限等级。

九、诚实的限制

  • 游客档是拉模式,收不到推流(真人说话/走动都不会主动推给它), 这是刻意的成本控制,防止长期占住名额
  • observe 输出有 ~2KB 预算,物体多的时候只能看到近处的
  • 每 IP 1 连接、10 票/小时、5 分钟空闲踢出
  • 世界里模型/图片的人工描述还大量空缺,AI 看到的是"只有名字没有介绍"

最后一条是内容问题不是技术问题:物体描述得靠人写。 我用了名称类型词自动推导覆盖了 365 个几何体,但模型和媒体必须人工填。

十、源码与体验

源码(MIT 协议,包含完整实现与 README):

github.com/miduo100/ag...

csharp 复制代码
# 想直接试:拉起 MCP server,然后把 AGENT_HOST 填成你的世界地址
npx -y agent-virtual-world

想亲眼看 AI 在世界里的样子:miduo100.com/agents(这是我的世界,进去后能看到 AI 形象和其他玩家)。 其余分发渠道:npm agent-virtual-world、官方 MCP Registry、Glama、Cursor Directory、Smithery、 魔搭 MCP 广场、Cline ------ 搜包名 agent-virtual-world 都能找到。

说明:这是我自己在做的开源项目,不是合作推广。世界可以自己部署, AGENT_HOST 换成任何兼容的服务器都能接。

相关推荐
asong1 小时前
从写代码到部署上线,Cloudflare 给 AI 配了一把新钥匙
前端·javascript·后端
lerhxx2 小时前
从 Markdown 到生成式 UI:AI 应用中的流式渲染实践
前端·javascript
可乐鸡翅yeah_3 小时前
新手梳理:HLS 线上问题,哪些该提给 CDN,哪些该找后端切片服务
开发语言·前端·javascript·vue.js·网络协议·http·m3u8在线
এ慕ོ冬℘゜3 小时前
es6基础
前端·javascript·es6
可乐鸡翅yeah_4 小时前
hls.js 缓冲区参数 maxBufferLength、maxBufferSize 通俗讲解与业务调参
开发语言·javascript·ios·音视频·safari·m3u8
paopaokaka_luck4 小时前
高校社团管理(AI辅助任务分配、协同过滤算法推荐、ECharts数据可视化、活动参与闭环、校园交流与反馈、器材借还管理)
java·前端·javascript·spring boot·数据分析·echarts
500845 小时前
React Native for OpenHarmony 实战:三方库 react-native-volume-control 的鸿蒙化适配指南
javascript·react native·react.js·electron·harmonyos
薛一半5 小时前
React-Redux三重优化实战揭秘
javascript·vue.js·react.js
Dovis(誓平步青云)5 小时前
导览音频切换太快,旧讲解不能覆盖新展品
android·前端·javascript·ecmascript·音视频·宠物