如果你只是给内部团队做一个小工具型 MCP Server,new一个 SDK 实例再注册几个函数上去,确实省事。但当你开始认真面对这三个问题——客户端必须通过鉴权、结果要流式回传、多个用户在多个会话之间的状态不能互相污染——你很快就会发现:框架替你封装得越舒服,你在问题真正出现时就越难下手。这篇文章记录的是我从零手写一个生产级 MCP Server 的完整思路,核心就是标题里那三件事:鉴权、流式传输与状态管理,顺带把生产级绕不开的日志管理也一起说透。内容不适合拿来做五分钟 demo,适合当你真正决定把一个 MCP Server 当作正式服务去维护的时候来参考。
1. 不盲从 SDK:哪种 MCP Server 值得自己从协议层写
先泼一盆冷水。市面上的 MCP SDK 已经非常成熟,Python 有 FastMCP,TypeScript 有官方 SDK,如果你只是把两三个工具包进去、跑在本地、给一家公司内部的 Agent 客户端调用,那没必要手写。但 SDK 不是万能的,它的价值在于掩盖复杂性,代价是让你失去对细节的控制。下面这几种情况,我强烈建议你从协议层自己实现,而不是继续在 SDK 封装里打补丁。
1.1 现成 SDK 的边界可能正好卡住你
SDK 最大的问题是它预设了传输方式。很多 SDK 的主路径是 stdio 或者简单的 HTTP 端点,但它对"长连接多路复用""断线续传""自定义鉴权协议"这些生产级需求,往往只留了一个很薄的扩展口。出了问题时,你会发现自己不是在写业务代码,而是在读框架源码,研究它内部的Server类到底在哪一层帮你把 response 写回给了客户端。
更现实的一个问题是版本绑定。MCP 协议本身还在演化,SDK 往往跟踪协议版本比较激进。你的客户端可能用的是另一个规范版本,两边对capabilities字段的协商逻辑就对不上,SDK 的默认握手流程可能直接给你返回一个兼容性错误。你无法通过配置解决,只能把协议层拆开。
1.2 手写不意味着造轮子:你只是在控制传输层
很多读者听到"从零手写"会以为要自己发明一套协议。不是的。MCP 协议本身基于 JSON-RPC 2.0,它的核心规范是很薄的——你只需要自己实现消息的编解码、请求分发、生命周期管理,以及连接层怎么接收和发送字节流。协议的语义、方法名、参数结构全部参考公开规范,唯一的工作量在于把"规范"翻译成"代码结构",以及把你自己的生产要求做进去。
这就像你自己写 HTTP 服务器,不用从 TCP 栈写起。JSON-RPC 的消息结构足够简单,手写的成本并不高,但换来的是你完全知道每一次鉴权发生在哪个环节、流式连接是怎么维护的、状态是怎么隔离的。这套掌控感,在上生产之后极其值钱。
1.3 适合手写的三种典型场景
结合我自己的项目经历,下面三类场景最适合直接手写。第一个是安全敏感型场景。如果你做的 MCP Server 要封装数据库查询、命令执行、文件读取这类高权限工具,安全审计人员很可能需要逐行确认鉴权逻辑、路径校验逻辑、敏感参数脱敏逻辑。SDK 的中间件层能掩盖的东西太多,手写之后所有路径都透明。
第二个是高并发流式场景。比如一个日志分析工具,或者一个实时数据管道工具,工具一调用就要持续向外推几万条结构化事件。这时候你需要精确控制每一个连接的消息帧写入,SDK 的抽象层反而碍事。
第三个是多协议接入场景。你的 Server 可能既要服务远程 HTTP 客户端,也要服务本地 stdio 进程,SDK 如果只封装了一种传输方式,你就得自己补另一条路。手写之后,底层核心逻辑只需要一份,不同的外层传输适配器各自实现,反而更干净。
2. JSON-RPC 骨架:先把消息分发和生命周期握手的每一行写明白
MCP 的整个通信语义都建立在 JSON-RPC 之上,所以手写的第一步不是写工具,而是先写出一个让你心里踏实的消息分发内核。这个内核负责三件事:把收到的字符串变成结构化消息、判断它是请求还是通知还是响应、路由到具体处理方法。很多生产问题——比如重复调用、消息乱序、响应和请求对不上——都发生在这个薄薄的一层里。
2.1 三类消息与一次完整握手的时序
JSON-RPC 2.0 的消息分三类:请求(有id,期待响应)、通知(没有id,不期待响应)、响应(result或error字段,用于回应某个请求的id)。MCP 在这个基础上做了一层生命周期约定:客户端必须先发initialize请求完成握手,再发notifications/initialized通知告知服务端初始化完成,之后才能调用tools/list、tools/call等方法。这个时序是服务端状态机的天然骨架——你在initialize之前收到的任何除了握手之外的方法调用,都应该直接拒绝。
一次完整握手的语义是:客户端上报自己的协议版本、客户端能力(capabilities)和客户端信息;服务端返回自己支持的协议版本、服务端能力(capabilities)和服务端信息;两边以较新但兼容的协议版本为准。注意一个细节:服务端返回的protocolVersion必须是客户端发来的版本或者向上兼容的版本,否则客户端会直接判定握手失败。
2.2 分发器实现:怎么区分请求、响应与通知
分发逻辑是整个 Server 最核心的代码,也是后期排查问题最需要看的地方。我的建议是尽早把它做成一张"方法名到处理器"的映射表,而不是一长串if/else。这样新加一个工具方法、或者给已有方法套一个鉴权装饰器,都只是往表里加一行的事。
下面是我在 TypeScript 项目里实际用过的分发器骨架。它没有依赖任何框架,核心处理函数只有两步:先判断消息有没有method(有就是请求/通知,没有就是响应),再说有没有id(有就是请求,没有就是通知):
const methodHandlers: Record<string, MethodHandler> = { "initialize": handleInitialize, "tools/list": handleToolsList, "tools/call": handleToolsCall, "notifications/initialized": handleInitialized, "notifications/cancelled": handleCancelled, }; async function dispatch(raw: string, ctx: RequestContext) { const msg = JSON.parse(raw); // 没有 method,说明这是第三方发来的 response if (msg.method === undefined) { const resolver = pendingResponses.get(msg.id); if (!resolver) return; // 迟到的响应,直接丢弃 pendingResponses.delete(msg.id); resolver.resolve(msg); return; } const handler = methodHandlers[msg.method]; if (!handler) { ctx.connection.write(makeError(msg.id, -32601, `Method not found: ${msg.method}`)); return; } // 没有 id 是 notification,不期待返回值,可以异步执行 if (msg.id === undefined) { await handler(msg.params, ctx); return; } try { const result = await handler(msg.params, ctx); ctx.connection.write(makeResult(msg.id, result)); } catch (err) { ctx.connection.write(makeError( msg.id, err instanceof McpError ? err.code : -32000, err.message )); } }这段代码虽短,但它做了几个很必要的决策。第一,pendingResponses这张表用于匹配异步的客户端响应,比如服务端主动发起的请求。MCP 允许服务端向客户端发起请求,比如请求客户端的日志级别,这在实际项目里很容易被忽略。第二,notification 都不塞进响应逻辑里,直接异步执行,避免把客户端的一个通知阻塞在耗时操作上。第三,异常统一从try/catch收口,任何处理器抛错都会变成规范的 JSON-RPC 错误响应,不会出现半截消息。
2.3 错误码与协议边界约定
JSON-RPC 的错误码有保留段:-32700表示解析错误,-32600表示请求不合法,-32601是方法不存在,-32602是参数不合法,-32603是内部错误。而-32000到-32099这一段是留给服务端自定义的。生产级的 Server 应该把"业务错误"和"协议错误"分开。协议错误一定发生在消息层,比如参数类型不对、方法不存在;业务错误发生在工具执行层,比如数据库查询失败、文件不存在。我建议自定义错误码时不要用-32000兜底所有场景,至少要区分出"鉴权失败""权限不足""会话不存在""执行超时"这几个高频情况,否则客户端拿到错误还要去猜。
另外,协议边界还包括请求体大小。JSON-RPC 的单个消息虽然不大,但在公网场景,一个几 MB 的伪造请求就可能打满你的内存。生产级实现应该在解析之前就校验Content-Length,对超过上限的请求直接返回-32600,而不是等到JSON.parse报错。这一步不是防御性编程,是必要的自保。
3. 鉴权体系:从 Bearer Token 到 OAuth 2.1,以及绕过鉴权的常见攻击面
鉴权这部分是生产级 MCP Server 和 demo 的分水岭。demo 可以完全没有鉴权,但生产级不行,因为 MCP 工具往往直接对应着数据操作能力。而且 MCP 的鉴权有个容易被忽略的特点:它不是一次性握手,而是每一次方法调用都可能涉及资源访问。换句话说,鉴权不能只发生在连接建立时,必须在每次请求时都重新验证。
3.1 鉴权要管三件事:连接、方法、工具
把鉴权拆开看,其实是三层。第一层是连接鉴权:这个客户端有没有资格连接上来。对于远程 HTTP/SSE 场景,通常用 Bearer Token 或者 Cookie 会话;对于本地 stdio 进程,理论上鉴权压力很小,但如果这个 stdio 进程是从不可信环境启动的,你仍然需要在消息里传递身份信息。
第二层是方法鉴权:一个已经连上的客户端,能不能调用tools/call?能不能调用prompts/get?这些方法代表不同的风险级别。例如只读的resources/read可能允许所有登录用户,而tools/call往往需要更高的权限。这一层做不好,低权限用户就可以通过直接拼 JSON-RPC 消息来调用他们本不该接触的方法。
第三层是工具鉴权:客户端虽然能调用tools/call,但具体到db_query这个工具是否被允许?参数里要访问的表是否在授权范围内?这一层是最细粒度、也最容易忘记的。很多实现只在方法层做了拦截,结果客户端可以通过改一个参数名就访问到另一个项目的资源。
3.2 Token 校验与"每次调用都重新鉴权"
静态 Bearer Token 是最简单的鉴权方式,也是很多内部 MCP Server 的首选。实现上要注意几个问题:Token 不能放在代码和仓库里,要通过环境变量或专门的配置中心注入;Token 要支持过期轮换,不能一辈子不变;校验端要使用常数时间比较,避免时序侧信道攻击。
核心原则是每次方法调用都重新做一次 Token 校验,而不是在连接建立时校验一次就放行。原因很实际:Token 可能在连接存活期间被撤销(用户注销、权限变更、安全事件),如果只在连接时校验,被撤销的用户可以一直占着连接调用工具到天荒地老。我实际踩过这个坑,当时为了图省事在 SSE 连接建立的回调里校验 Token,结果权限系统那边撤销了某位同事的令牌,连接却一直有效,让该同事继续读了一个多小时的数据。从那以后,我所有的请求处理链路上都加了一层withAuth装饰器。
下面是一个可以在分发阶段直接套用的装饰器模式:
function withAuth(handler: MethodHandler, requiredScopes: string[]) { return async (params: any, ctx: RequestContext) => { // 1. 从请求头取 Token,解析 Bearer const token = parseBearer(ctx.headers.authorization); if (!token) throw new McpError(-32001, "missing credentials", 401); // 2. 校验 Token 的签名与过期时间 const principal = await tokenStore.verify(token); if (!principal) throw new McpError(-32001, "invalid token", 401); // 3. 校验作用域是否满足本次调用的最低要求 const granted = new Set(principal.scopes); const denied = requiredScopes.filter((scope) => !granted.has(scope)); if (denied.length > 0) throw new McpError(-32002, "insufficient scope", 403); // 4. 把解析出的身份信息塞进上下文,供后续执行使用 ctx.principal = principal; return handler(params, ctx); }; }注意我用的是Throw的方式把鉴权失败传到外层分发器的try/catch里,而不是在装饰器里直接写错误响应。这样做的好处是,鉴权失败和其他业务异常走同一条错误出口,错误格式完全统一,排错时不用看两套逻辑。-32001和-32002是自定义错误码,分别代表"身份无效"和"权限不足",这比客户端拿到一个笼统的-32000再去猜要好得多。
3.3 绕不过去的防绕过清单
鉴权最怕的不是算法不强,而是逻辑漏洞。协议解析层、路由层、中间件层都有可能成为绕过鉴权的入口。我这里整理一份自己排查时反复对照的清单:
- URL 路径规范化:如果鉴权中间件只精确匹配
/tools/call,那客户端请求/tools/call/、//tools/call、/tools//call时,路由匹配结果可能不一样。很多 Web 框架会折叠路径,你的鉴权过滤器如果挂在路径匹配之外,就会漏掉这些变体。必须统一在进入路由之前先做一次路径规范化,再拿规范化后的路径做鉴权。 - 大小写:HTTP 方法理论上敏感,但不少框架不敏感。客户端把
POST /tools/call改成post /tools/call,如果你的路由大小写不一致,鉴权层可能放行。 - 消息包体伪装:JSON-RPC 允许
params里带任意层级的嵌套对象。如果你的工具处理器内部直接根据params.tool值去查对应的执行器,就要小心客户端通过参数注入来绕过工具级鉴权。比如一个只允许执行get_user的 Token,如果把tool字段传成delete_user,而工具鉴权只检查外层方法名,就出事了。 - 错误信息泄露:鉴权失败的响应不要带内部信息。比如 Token 是过期还是签名错误,不要细说;服务端版本号、堆栈片段、底层存储类型,都不要出现在错误响应里。统一返回一个中立的错误码和一句话描述。
这些点单独看都很小,但组合起来就是真实攻击路径。我自己维护的 Server 每次发布前都会用一份自动化脚本,把这些路径变体全部打一遍,确保全部返回401/403,没有漏网的 200。
3.4 从静态 Token 升级到 OAuth 2.1 的思路
如果你的 MCP Server 要开放给第三方开发者,或者要接入企业统一身份体系,静态 Token 就不够用了。MCP 官方对 OAuth 2.1 的支持方案已经很成熟,核心是 Authorization Code + PKCE 流程。写起来不复杂,但这里只说三个工程要点。
第一,你至少要实现三个端点:授权端点、Token 端点、以及用于刷新令牌的端点。Token 端点返回的access_token进入你的 MCP 请求头,refresh_token用来长期维持会话。第二,Token 应当包含足够的声明,至少要有sub(用户标识)、scope(权限范围)、exp(过期时间)。用 JWT 的话必须校验签名和过期时间;用不透明 Token 的话,需要把 Token 的元数据存在 Redis 等后端里。第三,也是最容易被忽略的:MCP 的initialize握手阶段就要透出认证能力,让客户端在一开始就知道要走哪个授权端点。规范里的capabilities里有对应字段可以声明。
4. 流式传输:SSE 的帧格式、并发写锁与连接生命周期治理
MCP 的远程传输有两个主流方案:一个是 SSE(Server-Sent Events),一个是 Streamable HTTP。两者底层都有流式语义,都需要你把"往连接上写数据"这个操作当成一双可以正确穿鞋的脚。很多手写实现跑单个工具没问题,多工具并行一上线就出现消息交错、半截 JSON、客户端解析失败,原因几乎都出在流式写入没有做串行化。
4.1 SSE 帧格式与服务端推送模型
SSE 本质上是一条 HTTP 长连接,服务端持续往响应体里写text/event-stream格式的文本帧。每一帧由若干field: value行组成,帧与帧之间用一个空行分隔。标准格式如下:
event: message data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progress":45}}客户端的 EventSource 解析器收到后会按event字段派发事件,data字段作为载荷。你可以在data里放任意 JSON 字符串,MCP 的 JSON-RPC 消息就是这么被封装在 SSE 帧里推给客户端的。
实际项目里,我建议不管用什么框架,都在应用层封装一个统一的send(event, data)方法。这样业务代码不会接触到 SSE 的文本帧细节,只负责发 JSON 对象。未来如果要迁移到 Streamable HTTP,只需要替换这个send方法的实现,业务逻辑完全不动。
4.2 并发写连接的正确姿势:每连接一个串行队列
SSE 连接最隐蔽的坑就是并发写。假设你的 Server 同时执行了三个工具,每个工具完成时都会往同一个 client 的 SSE 连接上写一条消息。如果这三个写操作同时发生,底层 TCP 写入会互相穿插,客户端收到的可能就是{"jsonrpc":"2.0","id":1....data: {"jso这样的碎帧,JSON.parse 直接失败。
解决方案说起来很简单:每个连接维护一个异步串行队列,所有写入放到队列里依次执行。先入队的写操作完成后,再写下一个。我之前在实现里为了省事直接用了一个链表式 Promise 链,效果很好,代码如下:
class SseConnection { private writeQueue: Promise<void> = Promise.resolve(); async send(event: string, payload: unknown) { const frame = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`; // 链条 +=,而不是直接 await,让队列保持连续不断 this.writeQueue = this.writeQueue.then(() => this.rawWrite(frame)); // 如果队列前面某个写操作挂了,要在链尾接一个恢复,防止整条链死掉 this.writeQueue = this.writeQueue.catch(() => {}); } }这里有一个很容易忽略的点:Promise 链上如果有一个rawWritereject 了,后续的.then都不会执行,整条队列就死掉了。所以我在每次追加后立刻跟一个空的.catch(() => {}),保证队列链本身永远是 resolve 态,单个写失败不影响后续消息。这个细节让我少了很多通宵排查的时间。
4.3 心跳、断线恢复与空闲连接回收
生产环境的长连接不可能是永远稳定的一根管子。SSE 连接会因为网络切换、代理超时、服务器内存压力等各种原因断开。客户端重连之后,服务端要能识别出这是同一个会话,并且把断线期间错过的消息补偿回来。
一种低成本方案是:服务端给每一类需要补偿的事件带一个单调递增的序号(或者复用 JSON-RPC 的id),客户端重连时把最后收到的序号通过查询参数或请求头带回来,服务端从序号之后开始重推。这个机制不需要消息队列,只要在内存里给每个会话保留一个已发送消息的环形缓冲就够了。缓冲大小根据你的实际吞吐决定,比如保留最近五分钟的 1000 条消息。
至于空闲连接回收,我建议做两级:每个 SSE 连接如果 60 秒没有任何读写,服务端发一个心跳注释行(: ping)探测存活;连续三次没收到响应,就把连接标记为失效并回收。配置项不要写死在代码里,至少要把心跳间隔和空闲上限开放成环境变量,因为不同部署环境对代理超时时间的要求差异很大。
另外再提一句 Streamable HTTP 的移植。它的思路是把"响应"也当成一个可读流,服务端在处理 POST 请求时可以直接返回一个text/event-stream响应体,边执行边往里写,客户端从同一个响应流里读。实现上你只需要把前面SseConnection的rawWrite替换成向响应体写入即可,队列和心跳的逻辑可以完全复用。
5. 状态管理:会话状态机、调用状态机和跨请求上下文
状态管理是手写实现里最容易被"懒政"搞砸的部分。有人直接把所有状态塞进一个全局对象,工具一多,不同用户之间的数据互相污染;有人完全不做状态,导致客户端断线重连后所有上下文丢失。生产级的状态管理核心是三套状态机:会话生命周期、工具调用生命周期、跨请求上下文传播。
5.1 先从会话状态机说起
一个会话从建立到关闭,至少要经历三个阶段:awaiting-initialize、initialized、closed。在awaiting-initialize阶段,服务端只接受initialize和notifications/initialized,其他方法调用一律拒绝。这个约束不是形式上走个过场,它保证了任何可能依赖会话上下文的工具调用,都不会在初始化不完全时被执行。
我在实际实现里给每一个会话维护了下面这样一个不可变状态字段:
type SessionState = | { phase: "awaiting-initialize"; connectedAt: number } | { phase: "initialized"; sessionId: string; userId: string; protocolVersion: string } | { phase: "closed"; reason: "timeout" | "client_disconnect" | "server_shutdown" };注意phase的赋值必须是全量更新,不能原地改字段。这样所有依赖会话状态的地方拿到的都是一个快照,不会出现某个处理器读到一半、另一个线程把状态改了的问题。会话的销毁也要做成一个幂等操作:同一个 sessionId 的销旧请求同时到来,只有第一个真正执行清理,后续的直接返回成功但不重复执行,这就避免了重复释放资源引发的崩溃。
5.2 工具调用的五态生命周期
每个工具调用从进入服务端到返回结果,应该有一条完整的状态流转路径。至少要有五个状态:received(已接收)、executing(执行中)、completed(完成)、failed(失败)、cancelled(已取消)。这样设计的价值在于:客户端如果发来notifications/cancelled取消请求,服务端能明确知道自己正在跑的是哪一个任务,可以针对性地中断执行;如果执行超时,服务端也能定位到具体的receivedAt和executingAt时间点。
type CallState = | { status: "received"; receivedAt: number; requestId: number } | { status: "executing"; startedAt: number; requestId: number } | { status: "completed"; result: unknown; finishedAt: number } | { status: "failed"; error: McpError; finishedAt: number } | { status: "cancelled"; reason: string; finishedAt: number };调用的状态也要和进度通知关联起来。工具执行过程中可以持续通过notifications/progress推送进度,这些进度的requestId必须指向当前调用的requestId,不能只发一个进度数字。否则客户端拿到 N 条进度,却不知道属于哪个任务,整个界面都会陷入混乱。
5.3 跨请求上下文:把鉴权结果和临时数据传下去
一个会话内可能连续发起多个工具调用,而这些调用之间往往需要共享一些上下文。比如先有一个工具创建了临时工作目录,随后另一个工具在这个目录里写文件,最后第三个工具在同一个目录里启动编译任务。这个工作目录就是跨请求的上下文。
我的做法是给每个会话维护一个"上下文快照",包含这样几个字段:会话绑定的用户 ID、鉴权后的角色和权限范围、会话级临时变量、以及本次请求的 trace ID。每次处理新请求时,先用会话 ID 取出这个快照,请求处理完成后把快照里可变的部分写回仓储层。读写必须加锁或走事务,否则两个工具同时改会话变量时会出现互相覆盖。
跨请求上下文最需要注意的是数据隔离。如果不小心把上下文对象做成了全局单例,两个用户的会话就会共享同一个工作目录,A 用户创建了目录,B 用户的工具调用就能进去读写。我见过一次线上事故,就是把工作目录放进了全局变量,结果所有用户串到了同一个临时文件里。从那以后,我所有的上下文对象都强制从会话仓储层取出,用完归还,不允许挂在进程全局变量上。
5.4 存储选型:内存、Redis 还是数据库
状态存储的选型没有银弹。单个实例部署、会话数量不大时,进程内存是最佳选择,速度最快、实现最简单。但要注意两点:内存存储的会话数据必须在进程退出时优雅清理;如果部署了多个实例,内存方案完全失效。
多实例部署时,我会选择 Redis,以session:{sessionId}为 key 存上下文快照,同时用 TTL 实现空闲会话自动过期。Redis 的原子操作还能很方便地实现并发锁。至于关系型数据库,适合状态里包含大量结构化数据和复杂查询的场景。比如你想统计每个用户调用了多少次某个工具,或者想追溯某次任务的完整状态流转,这时候数据库的查询能力就体现出优势了。
生产环境的稳妥做法是区分热数据和冷数据:会话热数据(执行中的状态、上下文快照)放 Redis;冷数据(完场记录、审计日志)异步写数据库。热数据过期了可以重建,冷数据丢了就真的没了,两者用不同的可靠性策略。
6. 日志管理:MCP Server 的日志如何正确送入自定义日志体系
"日志"在 MCP Server 里是一个反直觉的话题。很多第一次手写的人会在这里栽跟头,因为他们习惯了在业务代码里console.log一下。但在 MCP 里,stdout是协议通道,不是日志通道。你往stdout里打日志,客户端直接解析失败。这个坑非常经典,值得单独拿出来讲。
6.1 stdio 模式的经典陷阱:stdout 不能被污染
MCP 的 stdio 传输模式下,客户端通过子进程的 stdin 发送 JSON-RPC 请求,服务端通过 stdout 返回响应。也就是说 stdout 是双向通信里的响应通道,里面只能写协议帧。如果你在代码里顺手console.log("user xxx called"),这行日志就会混进 stdout 流,客户端把它当成 JSON-RPC 消息去解析,直接报错。所以 stdio 模式下的日志只能写 stderr。
但即便是写 stderr,也不能满足生产级需求。生产系统的日志要进统一采集平台、要带 trace ID、要支持按关键字检索,而不是光秃秃地打几行文本。所以问题真正的解法不是"日志写哪",而是"日志怎么接入自定义日志管理体系",核心是让核心协议层完全不依赖任何日志库,而是依赖一个你自己定义的 logger 接口。
6.2 面向接口的 Logger 注入:核心层不依赖具体日志库
我最开始实现时直接用了一个流行日志库的全局单例,后来发现这个库的版本升级对整个 Server 的影响范围太大,测试的时候要 mock 一堆内部状态,烦不胜烦。后来我做了重构:核心层只定义最小必要接口,依赖注入由启动入口负责,日志库只是一个实现了该接口的细节。
interface Logger { info(entry: LogEntry): void; warn(entry: LogEntry): void; error(entry: LogEntry): void; } interface LogEntry { ts: string; level: "info" | "warn" | "error"; trace_id: string; session_id?: string; event: string; tool?: string; duration_ms?: number; props?: Record<string, unknown>; }这样做的第一个好处是核心层完全不知道日志是写到文件、标准错误、还是 Kafka,测试时传一个内存 logger 就能断言日志内容。第二个好处是你可以针对每条日志的event字段做结构化处理,比如tool_call_started和tool_call_finished的时间差可以用 trace ID 串联成一个完整的调用链路。
6.3 trace_id 贯穿与结构化日志字段
生产排障时最值钱的就是关联性。一个用户从客户端发起请求,到鉴权、到工具执行、到数据库查询、到把结果推回,整条链路应该共用同一个trace_id。这个 ID 在连接建立时生成(或者从客户端请求头里透传),然后通过上下文对象传递到每一个处理环节,日志里必须包含它。
我建议在服务端入口处做一个统一拦截,在所有日志前面自动附加 trace_id、session_id、请求方法名等公共字段,这样业务代码里不需要每次手动带这些参数。下面的 JSON 是我在项目里实际产生的日志格式:
{ "ts": "2025-06-18T10:23:41.012Z", "level": "warn", "trace_id": "7f1c9a2e4b8d4a1c9f0e6d3a2b5c8f01", "session_id": "sess_01h3x1y2z3", "event": "tool_call_failed", "tool": "db_query", "duration_ms": 231 }这种日志可以直接送进任何结构化日志采集平台,按trace_id检索时能把同一请求的全部日志捞出来,按tool统计时可以快速发现哪个工具耗时异常。如果你的团队有自定义日志管理体系,只需要为它写一个实现了Logger接口的适配器,核心层一行不用改。
7. 发布前的生产级检查清单
框架搭好、功能跑通之后,距离"生产级"还差最后一道工序。这里把我在多次发布中沉淀的最关键检查项列出来,内容不长,但每一条都是真金白银踩出来的。
7.1 超时、并发配额与优雅退出
发布之前先问自己几个问题:单个工具调用最长允许执行多久?是 10 秒还是 60 秒?你有没有给它设置超时并在超时后取消底层任务?如果一个客户端一次性并发调用 100 个工具,你的 Server 是照单全收还是按配额拒绝?这些不是优化问题而是安全问题——没有配额控制的 Server 很容易被一次无意的批量调用挤爆。
优雅退出是另一个容易被忽略的点。进程收到 SIGTERM 时,不能直接process.exit(0),要先把新请求拒绝、让正在执行的任务跑完或进入取消流程、把未写入连接排空、再把会话状态落盘或清理。这整套流程看似繁琐,但每次发布升级时,集群里其他连接不会因为你的重启而出现半截状态。
7.2 自测清单与最小发布集
最后送大家一份上线前自测清单,照着跑一遍,能拦掉大部分低级事故。第一,用一个无 Token 的客户端连接,确认握手之前的所有方法都被拒。第二,用低权限 Token 调用高权限工具,确认得到 403 而不是工具结果。第三,同时发起 50 个工具调用,然后杀掉客户端进程,确认服务端在空闲超时后回收了所有相关连接。第四,重启服务端,用一个旧的 session ID 连上来,确认得到明确错误而不是静默失败。第五,停掉 Redis,在有状态流量的情况下确认服务端返回的报错足够明确,而不是挂死。
这些检查做完,你手里的 MCP Server 虽然代码量不大,但已经具备了上生产的基本素质——鉴权链路清晰、流式写入串行、会话状态隔离、日志可追踪。我自己的体会是,手写一遍 MCP Server 并不意味着从此拒绝所有 SDK,而是当你真的需要深入到协议层去解决问题时,你有据可查、有路可退,不必在黑盒里瞎猜。如果你正准备做或者正在做同样的事,希望这篇能帮你少走几个我走过的大弯。