1. 握手阶段就断连:MCP 服务器报「不完整的 VarInt 数据」到底卡在哪
如果你正在照着《从零构建 MCP 服务器》那篇文章写代码,本地把服务端跑在 25565 端口,客户端一连上就立刻断开,控制台里反复刷「不完整的VarInt数据」,那你踩到的其实是同一类坑:TCP 是流式协议,它不保证你一次recv就能拿到一个完整数据包。MCP 服务器(Minecraft Protocol,跨版本通信协议)的数据包结构是「长度前缀 + 数据包 ID + 数据负载」,前两段都是 VarInt 变长整数,长度前缀没读全,ClientHandler.process_buffer就只能break出去等下一批 TCP 数据,而客户端那边等不到握手回应,表现就是连上后秒断。
这个报错本身不复杂,麻烦的是它牵扯到三件事:VarInt 最长 5 字节的边界规则、长度前缀与包 ID 的对齐、以及握手阶段next_state的状态流转是否合法。文中也提醒过,握手阶段next_state没设对,就会踩到这类坑。我试过把 VarInt 编解码那段 Python 和完整报错一起丢给走 TaoToken 的 Codex,让它对着字节流逐字节核对,比自己在print里加日志快得多。这篇就按排障视角,把「怎么接 Codex」「怎么让它查」「怎么本地验证」讲清楚,数据包解析和状态机代码仍然由你在本地改和跑。
2. 前置:注册 TaoToken 并创建 Key,把 Codex 接进来
TaoToken 在这里的角色很单纯:给 Codex 提供 Key 和通道,让模型能稳定读到你的报错和代码片段。它不碰你的数据包解析逻辑,也不替你跑服务器。你可以先到官网注册账号,地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,注册完进控制台创建 API Key。
创建 Key 的入口在控制台里,直接打开 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 就能看到 Key 管理页;如果你习惯先看文档再动手,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有 Base URL 和调用示例。Key 生成后单独存好,别写进会提交到 Git 的配置文件。
接进 Codex 时,Base URL 填https://taotoken.net/api,注意这里不带/v1,也不加任何 UTM 参数。很多人习惯性补/v1,结果请求路径拼错,模型侧直接 404,反而以为是 Key 的问题。Key 就填你刚创建的那串,模型名按 Codex 侧要求选。配好之后先用一次最简单的对话确认通道通,再进入排障环节。
3. 可复制配置:把 VarInt 与状态机代码喂给 Codex
配置分两步:先把 Codex 的接入参数写对,再把要排查的代码和报错整理成一段可复制的上下文。接入参数建议放在环境变量或本地配置文件里,别硬编码。
# 环境变量方式(示例,按你本地 shell 调整) export TAOTOKEN_BASE_URL="https://taotoken.net/api" export TAOTOKEN_API_KEY="你的Key"如果你用的是支持 OpenAI 兼容接口的客户端,配置大致是这样:
{ "base_url": "https://taotoken.net/api", "api_key": "你的Key", "model": "按 Codex 侧要求填写" }接下来是排障的关键:把 VarInt 编解码和process_buffer一起贴给 Codex,并明确告诉它「VarInt 最长 5 字节,请逐字节核对长度前缀、包 ID、负载三段是否对齐,并检查 transition 的状态流转是否合法」。下面这段就是需要贴过去的核心代码,你可以直接复制:
class VarInt: @staticmethod def encode(value: int) -> bytes: buffer = bytearray() while True: byte = value & 0x7F value >>= 7 if value != 0: byte |= 0x80 buffer.append(byte) if value == 0: break return buffer @staticmethod def decode(buffer: bytes): value = 0 position = 0 for _ in range(5): # VarInt 最大 5 字节 if position >= len(buffer): raise ValueError("不完整的VarInt数据") byte = buffer[position] value |= (byte & 0x7F) << (7 * position) if not (byte & 0x80): break position += 1 return value, position + 1process_buffer那段也一起贴,重点是它先解长度前缀、再判断len(self.buffer) < pos + length就break的逻辑:
def process_buffer(self): while self.buffer: try: length, pos = VarInt.decode(self.buffer) if len(self.buffer) < pos + length: # 数据不完整,等待更多数据 break packet_data = self.buffer[pos:pos + length] self.buffer = self.buffer[pos + length:] packet_id, pos2 = VarInt.decode(packet_data) payload = packet_data[pos2:] self.server.packet_queue.put((self.client_id, packet_id, payload)) except Exception as e: print(f"数据包解析错误: {e}") break贴的时候把完整报错也带上,比如ValueError: 不完整的VarInt数据出现在哪一行、当时self.buffer的十六进制内容是什么。Codex 拿到这些,才能判断是「真的只收到半个包」还是「长度前缀算错了导致后续全部错位」。
4. 让 Codex 逐字节核对:长度前缀、包 ID、负载三段对齐
把上下文喂进去之后,提问方式决定了排查效率。别只问「为什么报错」,要给它可执行的核对指令。我一般会这样组织:
这是 MCP 服务器的 VarInt 编解码和 process_buffer。VarInt 最长 5 字节,每个字节低 7 位是数据、最高位是延续位。请按以下顺序核对:1)长度前缀解码消耗的字节数 pos 是否正确;2)
len(buffer) < pos + length的判断是否把长度前缀自身算进去了;3)包 ID 解码后 payload 的切片起点是否对齐;4)transition 的合法流转表是否允许当前 next_state。
这里最容易错的是第 2 点。长度前缀描述的是「包 ID + 负载」的总长度,不含长度前缀自己占的字节。所以判断完整性时应该是len(buffer) < pos + length,其中pos是长度前缀消耗的字节数。如果写成len(buffer) < length,就会在包 ID 还没到齐时误判为完整,接着VarInt.decode(packet_data)在残缺数据上抛「不完整的VarInt数据」。
另一个高频错点是decode的返回值。上面这段decode返回的是(value, position + 1),position在循环里是「当前字节下标」,正常退出时position指向最后一个有效字节,所以消耗字节数是position + 1。但如果数据在中间就break了(延续位一直是 1 但字节不够),position和实际消耗会对不上。Codex 会帮你把这条路径单独拎出来看。
状态流转这块,把MCPServerState.transition的合法表也贴过去:
valid_transitions = { "handshake": ["status", "login"], "status": [], "login": ["play"], "play": [] }握手包里next_state为 1 走 status,为 2 走 login。如果handle_handshake里解析next_state时偏移算错,读到的值不是 1 或 2,target_state就会落到 login 分支,而客户端其实想查状态,后续 0x00 状态请求发过来时状态机不认,连接自然断。让 Codex 顺着handle_handshake里pos的累加过程走一遍,能很快定位偏移错误。
5. 本地验证:25565 端口跑起来,看 0x00 与 0x01 是否正常回包
改完代码别急着下结论,本地把原文章的 25565 端口服务器跑起来验证。启动后先用客户端刷新服务器列表,观察两件事:状态请求 0x00 有没有正常回包、ping 0x01 有没有原样返回。
def handle_status_packets(self, handler, packet_id, data): if packet_id == 0x00: # 状态请求 status = { "version": {"name": "1.18.2", "protocol": 758}, "players": {"max": 20, "online": len(self.clients), "sample": []}, "description": {"text": "我的第一个MCP服务器"} } import json status_json = json.dumps(status).encode('utf-8') json_len = VarInt.encode(len(status_json)) handler.send_packet(0x00, json_len + status_json) elif packet_id == 0x01: # ping 请求 handler.send_packet(0x01, data)如果服务器列表能显示描述和在线人数,说明 0x00 通了;延迟数字能出来,说明 0x01 通了。这两步过了,再尝试进到 play 状态,看send_join_game和send_spawn_position有没有把客户端带进世界。验证时建议在process_buffer里临时加一行十六进制打印,把每次recv到的原始字节打出来,和 Codex 分析的结果对照,确认长度前缀、包 ID、负载三段确实对齐。
6. 本篇常见错排查
排障时按下面这张表逐项过,基本能覆盖「不完整的 VarInt 数据」引发的握手断连:
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 连上秒断,日志刷「不完整的VarInt数据」 | 长度前缀未读全就解码包 ID | 检查len(buffer) < pos + length是否用了pos |
| 状态请求无响应 | next_state解析偏移错,状态机没进 status | 核对handle_handshake里pos累加 |
| ping 不回包 | 0x01 分支未原样返回 data | 确认send_packet(0x01, data)未被改写 |
| 进 play 后立刻掉线 | transition("play")前状态不合法 | 检查 login 阶段是否先完成 0x02 |
| 偶发解析错、重连才好 | TCP 分包,一次 recv 拿不全 | 保留 buffer 累积逻辑,别清空 |
如果表里都排完还是断,把recv到的原始十六进制和 Codex 的分析结论一起贴回对话,让它重新核对一遍字节流。需要长期在编码和 Agent 场景里反复用这套排查流程的,可以看 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ;只是临时验证模型对字节流的理解,用模型对话就够:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=model&utm_campaign=rewrite 。Key 和接入参数在 API Keys 页管理:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite ,接入细节以文档为准:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。数据包解析和状态机代码始终在本地改、本地跑,Codex 只负责帮你对着字节流把偏移和流转核对清楚。