手写生产级MCP Server:从鉴权到流式传输的工程实战
发布时间:2026/9/28 16:05:14来源:尧图网络
1. 为什么放着现成 SDK 不用偏要手写一套 MCP Server先说说我这边的实际情况。过去两年我一直在做 AI Agent 相关的后端基建MCPModel Context ProtocolServer 从概念期到落地期几乎每个迭代都踩过一轮。市面上确实有各种语言的官方 SDK像 TypeScript SDK、Python SDK 都有HTTP 和 stdio 传输方式都封装好了拿过来 initialize、tools/list、tools/call 一套流程就能跑看起来没什么必要从零撸。但我遇到的第一个现实问题就是SDK 封装得太“温柔”了。它帮你处理了协议细节却也把很多该由业务层控制的东西藏起来了。比如鉴权逻辑SDK 通常只提供一个空壳接口让你填 token 校验但生产环境里 token 从哪来、过期了怎么办、按什么粒度刷新这些它一概不管。再比如流式传输官方 SDK 的流式实现基本是纯 SSE 推流一旦你的下游是某个不支持 SSE 的旧网关或者要求自定义帧格式SDK 这条路就直接堵死。另外还有一层更实际的压力MCP Server 是要部署到客户的私有化环境里的。客户对协议实现有自己的安全审计要求对“你到底在跑什么、日志能不能接到他们的统一日志平台、鉴权能不能对接他们已有的统一身份体系”都有明确要求。官方 SDK 在这方面的定制成本有时候比你从零写还高。所以我决定用 Node.js 从零手写一个生产级 MCP Server把鉴权、流式传输、会话状态管理三块硬骨头全部自己控制。这篇文章就讲讲手写过程中最关键的几个设计决策和完整实现路径不算什么高深理论但每一段都是我在生产环境里真正跑过的。这套东西适合谁看如果你是那种“用 SDK 能跑通 demo但往生产一上就四处碰壁”的开发者或者你需要在内部网络里做一个不被外部依赖绑死的 MCP 服务端这篇文章应该能省你不少试错时间。2. MCP 协议的关键边界与整体骨架设计手写之前我花了整整两天把协议规范重新过了一遍。MCP 的传输层和服务端逻辑其实是两件相对独立的事情搞清楚这条边界后面写代码就是线性推进。2.1 先搞清楚协议到底规定了什么MCP 的核心是一套基于 JSON-RPC 2.0 的消息协议。客户端发请求服务端回响应两边通过initialize方法协商协议版本和能力之后才能调用tools/list、tools/call、resources/read这些业务方法。它最大的特点是“一切皆能力协商”服务端在 initialize 的响应里声明自己支持哪些能力客户端再根据能力矩阵调用对应接口。生产级实现的难点不在于把这些 handler 写出来而在于你必须在每个环节都做好“协议边界检查”。举个例子客户端发来的 JSON-RPC 版本号格式不对、请求 ID 重复、方法名不在已协商能力范围内这些情况如果只依赖 SDK很多是吞掉或者直接报错但自己做实现时每条都得有明确的响应策略。我在骨架设计里把协议处理拆成了三层传输层负责接收原始字节流解析出完整的 JSON-RPC 消息帧会话层维护连接状态、能力协商结果、请求生命周期业务层处理 tools、resources、prompts 等具体业务逻辑这三层各干各的传输层永远不知道业务层在做什么业务层也永远不用关心消息是从 HTTP 来的还是从 stdio 来的。这样的好处是我后面要加一种新的传输方式或者把 HTTP 服务替换成内部 RPC只需要动传输层那几十行代码。2.2 JSON-RPC 消息帧的处理细节JSON-RPC 2.0 的消息格式看起来很简单就是一个 JSON 对象含jsonrpc、id、method、params四个字段。但实际生产里我在帧处理上吃了不少暗亏这里直接把我最终采用的方案贴出来。首先是消息拆分。如果走 HTTP 传输每个请求天然是一条消息这个还好。但如果走 stdio 或者流式传输必须自己做消息边界。MCP 官方推荐使用 JSON-RPC 消息后跟一个换行符来分隔但真实场景里客户端可能不按套路出牌消息中间可能混入多余空格、注释甚至二进制流干扰。我的做法是写了一个严格的消息解析器按状态机逐字节扫描只认 JSON 对象作为一个完整消息的边界。解析出错时不直接丢整个连接而是记一条结构化错误日志响应parse error错误码继续等待下一条消息。这个处理很关键——流式传输下一个脏帧就把整个连接掐断体验是非常糟糕的。我大概写了这么一段框架简化掉细节保持思路清晰class MCPFrameParser { private buffer ; feed(chunk: string): MCPJSONRPCMessage[] { this.buffer chunk; const messages: MCPJSONRPCMessage[] []; let start 0; let braceDepth 0; let inString false; let escaped false; for (let i 0; i this.buffer.length; i) { const ch this.buffer[i]; if (inString) { if (escaped) { escaped false; } else if (ch \\) { escaped true; } else if (ch ) { inString false; } continue; } if (ch ) { inString true; } else if (ch {) { braceDepth; } else if (ch }) { braceDepth--; if (braceDepth 0) { const raw this.buffer.slice(start, i 1); try { messages.push(JSON.parse(raw)); } catch (err) { // 记录错误但不中断连接 } start i 1; } } } this.buffer this.buffer.slice(start); return messages; } }这个扫描器很简单但对生产场景够用了。字符串内的花括号不会被误判碰到残缺的半条消息就暂存在 buffer 里等下一块数据而不是直接把连接标记为不可用。2.3 Server 能力矩阵的声明与校验MCP 的服务端能力声明在 initialize 响应里字段名为capabilities。这里我强烈建议不要在 capabilities 里声明任何你还没完全实现的能力。因为客户端拿到你的能力矩阵之后会在后续交互里疯狂调用对应的接口如果没实现却被声明了就会出现“握手成功、业务全挂”的诡异现象。我的经验是做一个能力注册表每注册一个真实的工具处理器才自动把对应 capability 加入声明。例如我维护了一个 Mapconst capabilityRegistry new Mapstring, boolean(); const toolHandlers new Mapstring, (params: any) Promiseany(); function registerTool(name: string, handler: (params: any) Promiseany) { toolHandlers.set(name, handler); capabilityRegistry.set(tools, true); }这样声明和能力实现天然绑定不会出现声明和实现脱节的问题。后面接入鉴权和流式传输时这层注册表也方便统一拦截——我可以统一包裹一层鉴权检查、一层审计日志而不用在每一个工具 handler 里重复写鉴权代码。3. 鉴权模块从 Token 校验到双层校验机制鉴权是我这次重构投入最大的一块。MCP 协议本身没有规定鉴权方式官方只说了“你可以用任何你已有的 HTTP 鉴权机制”等于把选择权完全交给了实现者。这既是好事也是坏事——好事是可以定制坏事是没人告诉你哪种方式在真实 AI Agent 调用场景里最稳。3.1 为什么普通的 Bearer Token 不够用大多数人第一反应就是 Bearer Token请求头带Authorization: Bearer xxx服务端校验一下通过就放行。demo 阶段完全没问题但生产环境你很快就会遇到三件事第一Token 可能从一个不受信任的网络通道里被截获。MCP Server 经常部署在客户内网内网不等于安全网络内网里的扫描器、跳板机、日志系统都可能把请求头记录到日志里。一旦 Bearer Token 进了日志单靠它的校验体系就废了。第二Token 的有效期和刷新机制不够灵活。一个重度 AI Agent 会话可能持续好几个小时Agent 在思考过程中会多次调用 MCP 工具。如果 Token 有效期设得太短Agent 端拿什么刷新它拿不到 refresh token因为它是 API 调用方不是 Web 登录用户。时效性长了风险敞口又大。第三缺少请求维度的完整性校验。Bearer Token 只能证明“你有这个 token”但无法证明“这条请求确实是由 token 持有方发出的、中间没有被篡改”。为了这个我后来引入了请求签名机制。3.2 我的双层鉴权设计Access Key 请求签名最终我采用的是“AK/SK 式签名鉴权”你可以类比云厂商 API 网关的做法。MCP 客户端在调用任何业务接口前先通过一个独立的管理接口获得一对 AKAccess Key ID和 SKSecret Access Key。SK 只出现在签发接口的响应里签发通道走的是内部 TLS 附加的客户端证书认证。之后每个业务请求要带上三个东西X-MCP-Access-Key客户端 IDX-MCP-Timestamp请求发起时间戳X-MCP-Signature对请求主体做的 HMAC-SHA256 签名密钥是 SK服务端校验时先从内存里根据 AK 查出对应的 SK然后用同样的算法计算签名比对是否一致。同时检查时间戳超过 300 秒的请求直接拒绝防重放攻击。这套方案比单纯 Bearer Token 的优势在于即使请求头被日志系统完整记下来攻击者拿到的也只是 AK 加签名没有 SK 就造不出合法签名。而 SK 只在一次性签发接口的响应里短暂出现泄露面就小了很多。核心校验代码长这样import { createHmac, timingSafeEqual } from crypto; async function authenticateRequest(req: IncomingMessage, body: string): PromiseAccessKeyInfo { const ak req.headers[x-mcp-access-key]; const timestamp req.headers[x-mcp-timestamp]; const signature req.headers[x-mcp-signature]; if (!ak || !timestamp || !signature) { throw new AuthError(MISSING_AUTH_HEADER, 请求缺少鉴权头部); } // 时间窗口检查防重放 const requestTime parseInt(timestamp, 10); if (Math.abs(Date.now() - requestTime) 300_000) { throw new AuthError(REQUEST_EXPIRED, 请求时间戳超出允许窗口); } const keyInfo await keyStore.getKeyInfo(ak); if (!keyInfo) { throw new AuthError(INVALID_ACCESS_KEY, AK不存在); } const expectedSig createHmac(sha256, keyInfo.secretKey) .update(${ak}\n${timestamp}\n${body}) .digest(hex); const actualBuffer Buffer.from(signature); const expectedBuffer Buffer.from(expectedSig); // 恒定时间比较避免侧信道攻击 if (actualBuffer.length ! expectedBuffer.length || !timingSafeEqual(actualBuffer, expectedBuffer)) { throw new AuthError(SIGNATURE_MISMATCH, 签名校验失败); } return keyInfo; }注意这里我用了timingSafeEqual这是个很多人不重视但很重要的细节。如果你的签名比对用的是攻击者可以通过测量响应时间逐字节猜测签名内容虽然在实际网络中这个攻击成本很高但生产级实现没有理由不把这点堵上。3.3 鉴权绕过我在压测里踩到的那个洞这个话题我得单独拎出来说因为它是真实踩过的坑。第一版实现里我以为只要在每个工具调用的 handler 前加上鉴权检查就够了。但后来压测和内部安全审计时我发现两个绕过路径第一个绕过路径是initialize 握手不鉴权。按照 MCP 协议流程客户端先发 initialize 请求协商能力。我当时想握手阶段没有业务数据不做鉴权也说得过去。结果安全审计的人一眼就看出了风险如果 initialize 也不鉴权攻击者可以无限发起握手请求消耗服务端资源。更关键的是某些 MCP 客户端实现会在 initialize 响应中带回服务端内置的工具信息列表这些信息本身就是敏感数据。第二个绕过路径是流式传输中的消息不鉴权。我的流式传输后来改成了分块响应模式第一版里我只校验了发起流式请求的那一次握手后续推送的每个数据块我不再校验。审计发现如果传输通道被中间人劫持伪造后续数据块的代价极低。修复方案是把鉴权逻辑层层铺开做成一个统一的中间件任何入口都必须先过鉴权再进业务逻辑。我封装了一个withAuth的包裹函数async function withAuth(handler: (params: any, ctx: RequestContext) Promiseany) { return async (params: any, rawCtx: RawContext) { const authInfo await authenticateRequest(rawCtx.request, JSON.stringify(params)); const ctx { ...rawCtx, userId: authInfo.userId, requestId: uuidv4(), }; // 注入审计日志 auditLogger.info(TOOL_CALL, { tool: rawCtx.method, userId: authInfo.userId, requestId: ctx.requestId, timestamp: new Date().toISOString(), }); return handler(params, ctx); }; }每个工具注册的时候都包一层withAuth注册工具的地方就变成registerTool(query_order, withAuth(async (params, ctx) { // 实际业务逻辑 }));这样就不会出现哪个 handler 漏掉鉴权。没有鉴权就是一个不可注册的状态从机制上杜绝了绕过。3.4 动态密钥轮换与无感知下线生产环境里SK 不能一成不变必须定期轮换。我实现了两种轮换机制定时轮换默认每 24 小时强制轮换一次由后台任务生成新的 SK旧 SK 进入过期列表保留 24 小时宽限期。事件触发轮换检测到可疑调用行为比如某个 AK 在短时间内请求频率异常立刻吊销并重新签发。密钥轮换最怕的是客户端正在调用过程中SK 突然失效导致请求大面积失败。我的做法是“双 Key 缓冲策略”一个新 SK 生成后并不立刻替代旧 SK而是进入 pending 状态服务端签名校验时同时允许旧 SK 和待生效的新 SK 通过等到新 SK 稳定使用一段时间后旧 SK 才真正下线。这样就实现了客户端无感知的平滑轮换。4. 流式传输SSE 之外的另一种思路与中断恢复流式传输是 MCP Server 里最能拉开体验差距的部分。AI Agent 调用一个工具做长耗时任务如果服务端要跑几十秒甚至几分钟接口还是老老实实等全部结果出来再返回客户端那边就一直在转圈。生产环境的 MCP Server 必须支持流式推送中间结果。4.1 官方的 SSE 方案和它的限制MCP 官方的流式传输方案是基于 SSEServer-Sent Events客户端建立一个长连接服务端往这个连接里不断推event: message帧。SSE 的优点是实现成本低、基于标准 HTTP、天然支持断线重连。但我在实际部署中碰到了三个限制第一SSE 的代理兼容性差。客户内网往往有旧的 Nginx、API 网关或者负载均衡器对 SSE 的text/event-stream响应支持并不好。有的网关会缓冲整个响应导致流式效果丧失有的干脆会把长连接超时断开。第二SSE 无法携带复杂元信息。每条 SSE 消息都被约束成data: ...的格式你很难在一条流里同时推送“进度百分比”“结构化中间结果”“业务错误码”这三种不同性质的信息。虽然能约等于用 JSON 包一层但协议的语义边界就很模糊了。第三SSE 断线恢复困难。客户端一旦断线再重连时服务端并不知道之前推送到哪个位置了。没有内置的事件游标机制需要自己设计恢复逻辑。当然了我不是说 SSE 不能用在小并发或可控网络环境下它是一种很务实的选择。但如果你的 MCP Server 要面对复杂的内部网络环境我建议认真评估一下自己实现一套基于 HTTP 分块传输Transfer-Encoding: chunked的流式方案或者基于 WebSocket 的二进制帧方案。4.2 我自己实现的流式传输框架综合考虑客户端兼容性和开发成本我最终基于HTTP 分块传输编码自己做了一套轻量流式协议。核心思路是一次工具调用正常走 HTTP 请求响应模式响应头里带上Content-Type: application/x-json-stream正文则是一段一段由\n分隔的 JSON 帧。每一帧有一个统一的格式{type:progress,seq:1,data:{percent:20,message:正在解析用户输入...}} {type:result,seq:2,data:{code:0,data:{orderId:12345}}} {type:error,seq:3,data:{code:5002,message:数据库连接超时}}type字段区分是进度事件、最终结果还是错误事件。seq是单调递增的序列号用来做顺序校验和断点续传。服务端实现方式其实非常简单——用 Node.js 的res.write持续往响应流里写数据就行。我封装了一个StreamWriter类class StreamWriter { private seq 0; private timer: NodeJS.Timeout | null null; constructor(private res: ServerResponse) { this.res.writeHead(200, { Content-Type: application/x-json-stream, Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no, // 关闭 Nginx 缓冲的关键头 }); this.res.flushHeaders?.(); // 确保连接立即建立 } write(type: StreamEventType, data: unknown) { this.seq; const frame JSON.stringify({ type, seq: this.seq, data }); this.res.write(frame \n); } finish() { this.res.end(); } heartbeat(intervalMs 15000) { this.timer setInterval(() { this.res.write({type:heartbeat,seq:${this.seq}}\n); }, intervalMs).unref?.(); } }有几个细节非常重要必须设置X-Accel-Buffering: no。这个响应头告诉 Nginx 等中间代理不要对这个响应做缓冲是流式响应能真正“流”起来的关键。没有这个头Nginx 默认会一直攒着数据直到攒够一定体积再垫给客户端你的流式就变成了“一顿一顿的假流式”。连接建立后要立刻flushHeaders()。让客户端能立刻拿到响应头知道自己连接的是流式通道而不是在等待服务端一次性算完。这个头一秒钟的延迟用户感知上差距都很大。加心跳帧。有些内网网关对长时间没有数据的连接会主动断开这是长耗时任务中最常见的断流原因。每 15 秒推一个极小的心跳 JSON 帧网关就会认为这个连接一直活着。4.3 断线续传流式传输里的硬骨头断线是流式传输无法回避的问题。一开始我以为只要客户端断开连接服务端直接终止管道把数据丢了就行。但真实场景里AI Agent 调用一个工具后可能已经展示了一部分中间结果给用户断线重连后要求服务端继续推剩余结果而不是从头再来。我实现了一个简单的基于 seq 的断线续传机制。服务端每次开始一个流式任务时把已经生成的 frame 序列追加到一个带 TTL 的 Redis 列表里键名类似mcp:stream:{requestId}。客户端重连时带上?requestIdxxxfromSeq12服务端从 Redis 里读出前 12 条已经推过的 frame然后从第 13 条继续推。这里有两个工程实现上的要点第一Redis 里的帧不能无限膨胀。长耗时任务可能一次生成几百帧每个帧几 KB几十个并发任务下来内存占用非常可观。我的策略是只保留最近 50 帧 每帧压缩后再存同时设置 TTL 为任务结束后的 10 分钟。超过 50 帧后只恢复“进度快照”而不是完整逐帧恢复客户端根据当前进度值自行渲染。第二断线续传要防止重复投递。客户端因为网络抖动可能并没有真的错过某帧但重连时又请求了从fromSeqxx开始的帧这时客户端需要根据 seq 去重。我建议客户端实现一个“最后一帧 seq 缓存”收到新帧时先判断 seq 是否小于已缓存的最新 seq是则直接丢弃。4.4 从长轮询到流式推送一个性能对比实测不写点真实数据总觉得文章不够接地气。我在重构前后做过一组简单对比压测同一个模拟工具单次调用耗时约 5 秒客户端分别用“普通同步请求”和“流式推送”两种模式做 100 次调用压测。结果是两者在服务端总耗时上没有本质区别毕竟活都是那么多活。差别体现在两个地方一是首字节时间TTFB。普通请求模式下客户端平均等待 5 秒后才收到第一个字节流式模式下服务端耗时被拆成了一小块一小块的进度帧首字节平均 180ms 就出去了。对 AI Agent 的“思考-执行-决策-再执行”循环来说这个差异直接影响了 Agent 判断工具是否卡死超时进而影响整体链路的稳定性。二是客户端超时率。我把客户端的读取超时设为 6 秒。普通模式下因为中间等待时间超过 6 秒100 次里大约有 15 次会被客户端误判为超时引发重试风暴流式模式下由于一直有帧在推客户端认为连接是活的误判率几乎为 0。这个实验对我触动很大。工程上很多问题真不是把服务端处理速度优化上去就能解决的用户体验和下游系统的稳定性有时候更依赖“数据能不能早一点到达客户端”这个看似简单的事实。5. 状态管理从“无状态”到“按会话恢复”的心路历程MCP 协议本身是尽量无状态的每次工具调用理论上都是独立的。但生产级 Agent 的场景天然有状态用户在对话里先创建了一个工单然后过了几分钟Agent 调用另一个工具时还要引用这个工单的 ID或者 Agent 在画一个数据图表的过程中服务端这边要持续维护图表配置的上下文。如果没有状态管理Agent 就不得不每次把全部上下文塞给工具参数既低效又容易出错。5.1 状态会话的拆分全局态、会话态、请求态我不想把所有状态都塞到一个大 context 对象里那样到后期根本分不清是谁的锅。我最终把状态分成三个层级全局态所有会话共享的配置类数据比如模型上下文、全局开关、公共工具列表。会话态一次完整的人机交互周期内的上下文用 sessionId 作为键里面存放 Agent 最近引用的业务对象、上下文快照、最近一次工具调用结果。请求态单次工具调用的临时数据比如请求的入参校验结果、鉴权信息、日志追踪 ID。核心注册表我是在内存里维护一个 Map 来实现的长生命周期大状态用 Redis 持久化。内存的好处是零序列化开销、读写极快Redis 的好处是进程重启后会话还在。我建议生产环境两类结合会话的“实时热点数据”放内存会话的“可恢复快照”放 Redis。这里有一个很实用的恢复技巧。MCP Server 进程重启前会把当前所有活跃会话的摘要信息不包含全部上下文只包含业务对象 ID 和关键字段写入 Redis。重启后如果收到一个携带旧 sessionId 的请求先从 Redis 恢复摘要信息再按需从业务数据库里捞详细数据。这样即使服务端在任务中重启了Agent 那边的会话也能尽量无缝继续。5.2 会话状态里面的坑泄漏与会话串号状态管理听着简单实际一上线就踩了两个坑这里都说了给大家避雷。第一个坑是会话状态泄漏。我把会话态放在一个 Map 里key 是 sessionId。但有一次压测发现某个测试账号的会话数据莫名其妙的出现在另一个账号的请求里。排查到后面发现是客户端侧没有正确携带 sessionId我的默认逻辑就直接用了“上一个请求的 sessionId”。这样设计原本是为了方便调试结果一旦客户端漏传 header就会串到别人的会话上。修复方法很朴素sessionId 缺失时不仅不允许继续而是直接拒绝请求并返回明确的错误码让客户端感知到“这个请求缺了关键上下文”。宁可让请求失败重试也不能让状态错乱因为状态错乱的诊断成本比请求失败高一个数量级。第二个坑是会话超时清理不及时。一个会话如果连续没有请求它占用的内存并不会自动释放。上线初期我甚至观测到一个吃满 1.5GB 内存的服务去 dump 才发现里面有几千个僵尸会话。后来我加了两个机制基于 LRU 的淘汰策略 定时扫描清理过期会话。具体来说每次访问会话时更新时间戳定时任务每 5 分钟清理超过 30 分钟没有活跃的会话。因为 MCP 场景都是机器对服务会话不可能是人工长时间停留30 分钟的过期时间是一个合理的默认值。5.3 与鉴权的联动会话维度的鉴权状态鉴权和状态管理不是孤立的它们必须联动。我在设计会话状态时特意在会话上下文中存储了鉴权快照当前会话由哪个 AK 发起、SK 的版本号、密钥轮换后该会话是否还能继续访问。当密钥轮换触发时旧 AK 进入宽限期但宽限期内这个 AK 关联的会话是否还能继续调用我的决策是可以继续调用但调用过程中返回的响应头里必须带上一个警告头告诉客户端“你的密钥即将过期或已过期请尽快通过新密钥重建立会话”。这样既不影响正在进行的长任务又给了客户端明确的续期信号。这就是当时我处理“AI Agent 正在跑长任务刚好遇到密钥轮换”这个尴尬情况的最终方案。直接切断会导致 Agent 整条链路失败不处理又有安全风险用“宽限期 主动告警”的方式把风险控制在了可观测范围内。6. 生产环境的最后一道关日志、错误码与联调细节主体功能都稳定了之后我把很大一部分精力花在了“生产可维护性”上。这部分在 demo 阶段没人管到了生产阶段每一项都能决定你的服务是否能被运维团队接受。6.1 自定义日志管理别让 MCP 的日志变成一团乱麻热词里“mcp server端的日志如何使用自定义日志管理”这个话题很对因为这正是 MCP Server 最容易忽视又最致命的问题。很多 MCP Server 的默认行为是直接console.log打印到 stdout。在本地调试没问题一到生产环境stdout 会被容器运行时统一采集然后所有工具的日志混在一起你根本分不清哪条日志对应哪次工具调用、哪个会话。我的做法是自建一个结构化日志模块核心是把三类日志分开管理第一类是访问日志记录每次工具调用的完整元数据。包括timestamp、requestId、sessionId、userId、工具名、入参摘要、出参摘要、耗时、错误码。这些日志会从 stdout 分离单独写入一个旋转文件或推送到统一的日志采集平台方便后续排查“哪个用户调了哪个工具”。第二类是审计日志记录与安全和数据访问相关的事件。包括AK 签发、密钥轮换、权限拒绝、会话过期、敏感数据读取。这一类日志必须走单独的审计通道保留时间也更长在等保或客户安全审计时能直接拿出来当作证据。第三类是错误日志只记录异常栈和关联的上下文追踪 ID。生产环境不允许靠肉眼看 log 来定位异常错误日志一定要带上 requestId 和 sessionId这样你才能从访问日志里反查出这个 requestId 经历了什么链路。我把日志模块设计成可替换的接口底层可以接 winston、pino、或者你们内部的自研日志库。核心只要求一点无论底层接什么日志字段的语义必须统一。这样 log query 的时候我可以直接按tool_namequery_order这样的通用语法查所有环境。6.2 错误码体系区分“客户端错”和“服务端错”MCP 基于 JSON-RPC错误码是有一个标准约定的但标准只约定了几个通用错误-32700解析错误、-32600无效请求、-32601方法不存在、-32602无效参数、-32603内部错误。真实生产环境里这几个码远远不够用。我在自己的实现里做了一个扩展错误码体系而且严格遵循一个原则错误码的百位和千位表示错误来源个位和十位表示具体原因。比如10xxx表示鉴权相关错误如10001是 AK 不存在10002是签名错误10003是请求过期20xxx表示业务参数错误如20001是必填字段缺失20002是字段格式非法30xxx表示工具内部错误如30001是数据库超时30002是下游服务无响应40xxx表示流式传输错误如40001是连接中断40002是帧解析失败这个体系的价值在于客户端收到错误响应后不用看错误描述文字只看错误码的第一个数字就能决定是“立刻重试”“调整参数再试”还是“直接放弃报告用户”。AI Agent 的判断链路里这种可机器判定的错误码比自然语言描述可靠得多。6.3 联调工具链Burp Suite 与调试路径联调这块也值得说说。MCP Server 的客户端有时候是个 AI IDE 插件有时候是个自动化脚本有时候是个内部网关。当你在开发阶段测试一个工具的时候没有一个友好的 HTTP 客户端会帮你“自动建立 initialize 握手”所以我自己写了一套针对 MCP 协议开发的调试脚本。首先是本地起一个完整的 MCP Server然后写一个最简的 MCP 客户端脚本通过 HTTP 协议发initialize→tools/list→tools/call三步调用把每一步的原始请求和响应都打进日志。这个脚本是我日常调试最依赖的工具——它能一秒钟确定问题是不是出在协议层而不需要反复在真实客户端里排查。另外如果你用的是 IDEA 或 VS Code 这类 IDE又需要让模型直接联调 MCP Server可以关注一下“把 Burp Suite 之类的流量代理工具接到 MCP 链路”的做法。搭建好之后你能在 Burp Suite 里直接观察到 MCP Server 的全部 HTTP 请求和响应在做安全审计时非常有用。我这边建过一个简单的链路AI IDE 插件的 MCP 客户端 → 本地代理的 MCP Server → Burp Suite 记录完整流量。看起来复杂其实本质就是把 MCP 的 HTTP 请求都导向代理端口然后用拦截工具看流量和平时抓普通 HTTP 接口一样走一遍就明白 MCP 的调用长什么样了。7. 手写过程中最值得沉淀的三条经验代码已经跑稳定了最后说说这次手写经历里最值得沉淀的三条经验也算是对“为什么值得手写”这个问题的最终回答。第一手写不是目的“理解协议边界”才是目的。用 SDK 时你会觉得“MCP Server 就是实现几个方法”只有自己把传输层、会话层、业务层拆开写一遍你才会意识到真正定义生产级服务的不是那些业务方法而是协议边界处的防御性设计——帧解析失败怎么处理、能力声明不实怎么避免、鉴权哪个环节不能漏、流式断线怎么恢复、会话何时过期。这些边界设计的好坏直接决定了服务能不能在客户环境里存活。第二鉴权、流式传输、状态管理这三件事必须联动设计不能孤立实现。我第一版就是各做各的结果密钥轮换时会话状态一片混乱流式传输断线续传时又发现没法关联到具体会话。重构后我把三者统一到一个“请求上下文”概念里每个请求进来第一件事是在统一的上下文对象里注入鉴权信息、会话元数据、流式通道句柄后续所有逻辑都从这个上下文取数。这样每个环节的决策都互相可见不会出现“鉴权通过了但会话已经过期”这种诡异状态。第三日志和错误码是生产级的手艺活不能最后补。我吃过亏第一版日志就是随手 console.log等到真的出了生产事故去排查时才发现根本没有办法从一堆 stdout 里恢复一次完整调用的链路。后来把结构化日志、错误码体系在前置设计里加进去之后的排查效率提升了一个数量级。如果你也在做类似的 MCP Server请一定把这一环当成和鉴权同等重要的基础能力来设计而不是“等有空了再说”。
网站建设高端定制企业官网