如果你最近在对接大模型应用大概率绕不开 MCP Server 这个词。手头这个项目不是做一个玩具 Demo而是要从零手写一个能上生产环境的 MCP Server重点啃三块硬骨头鉴权、流式传输、状态管理。这篇文章把我从架构设计到落地踩坑的完整过程记录下来包括为什么做这些技术选型、每个模块的代码骨架、以及生产环境里才会暴露出来的细节问题。适合已经了解 MCP 基本概念、想自己动手实现服务端的开发者也适合正在做 AI 应用集成、需要把工具调用和对话上下文玩明白的工程师参考。我在动手之前先定了一个原则不引入任何重量级 MCP 框架核心协议层自己实现只借用 Web 框架和基础库。这样做的理由很直接——框架帮你隐藏了太多细节一旦遇到鉴权绕过、连接状态异常、流式传输中断这类问题如果不懂底层原理排查起来会非常痛苦。自己实现一遍后续维护和扩展反而轻松。1. 整体架构设计与技术选型思路1.1 先搞清楚 MCP Server 在生产环境到底承担什么角色MCPModel Context Protocol本质上是给大模型应用提供“工具调用”和“资源访问”的标准化通道。Server 端要做的事情就是暴露工具、处理调用请求、返回结构化结果并且以流式方式推送长任务进度。生产环境比 Demo 复杂在三个地方调用方身份必须可信、超大响应不能一口气塞给客户端、多个会话之间的状态不能串。所以我在设计阶段把服务拆成了三层协议接入层、业务逻辑层、状态管理层。协议接入层负责处理 MCP 协议的数据帧格式、初始化握手、能力协商业务逻辑层就是具体的工具实现比如查数据库、调第三方 API、执行本地脚本状态管理层单独拎出来做是为了让会话恢复、进度追踪和鉴权信息解耦。三层之间通过内部接口通信不直接共享数据库连接或者内存缓存避免了一层出问题拖垮全局的情况。这种分层最大的好处是可以独立扩展。协议接入层扛不住并发就水平扩工具执行太慢就改成异步任务队列状态存储扛不住就换分布式缓存。每一层替换的成本都控制在可接受范围内。1.2 技术栈选型为什么用 Node.js TypeScript而不是 Python 或 Go技术栈的选择我纠结了一段时间。MCP 官方 SDK 对 Python 和 TypeScript 支持最好但我不打算用 SDK 封装好的高阶 API而是直接用底层类型定义自己实现协议逻辑。最终选 Node.js TypeScript 的理由有两点。第一TypeScript 的强类型对协议数据结构极其友好。MCP 协议的请求和响应都是 JSON-RPC 形式的包含 method、params、result 等字段类型定义写清楚之后IDE 自动补全和编译期检查能拦截大量低级错误。第二Node.js 的事件循环模型天然适合做流式传输。SSEServer-Sent Events在 Node 里实现非常顺手响应对象本身就是个流往里面写数据就行不需要像 Python 那样额外处理 WSGI/ASGI 的流式兼容问题。当然Go 的并发模型也很强Python 的生态更适合 AI 场景但我最终没有选它们的核心原因是这个 MCP Server 的定位是“应用接入层”未来大概率要嵌入现有 Node.js 技术栈的后端服务中同构技术栈可以减少运维和部署成本。如果你是从零开始且团队更熟悉 Python用 FastAPI 实现也完全没问题核心架构思路是通用的。1.3 目录结构和模块划分避免代码长成“屎山”生产级项目最忌讳的就是所有逻辑堆在一个文件里。我在开工前先把目录结构定好了这个结构在后续迭代中几乎没有大的变动src/ ├── protocol/ # MCP 协议层消息编解码、JSON-RPC 处理 │ ├── types.ts # 协议类型定义 │ ├── encoding.ts # 帧编解码、长度前缀 │ └── router.ts # 方法路由、错误处理 ├── auth/ # 鉴权模块API Key 管理、签名校验、限流 │ ├── apikey.ts # API Key 生成、存储、吊销 │ ├── hmac.ts # HMAC 签名校验 │ └── middleware.ts # Express 中间件 ├── server/ # 服务端核心初始化握手、会话管理 │ ├── handshake.ts # initialize 请求处理 │ ├── session.ts # 会话生命周期管理 │ └── sse.ts # SSE 流式传输实现 ├── state/ # 状态管理内存态、持久化、恢复 │ ├── store.ts # 状态存储接口 │ ├── memory.ts # 内存实现 │ └── redis.ts # Redis 实现 ├── tools/ # 具体工具实现 │ └── calculator.ts # 示例工具 └── index.ts # 入口每一层只依赖它下面的一层禁止跨层引用。工具层不直接读 HTTP Header鉴权信息必须通过上下文对象传递状态层不感知具体工具类型只存储序列化后的状态 blob。这条规则我在 code review 时盯得特别紧因为一旦跨层引用后面的流式传输和状态恢复逻辑会变得完全不可控。2. 鉴权模块实战从 Token 设计到中间件落地2.1 生产级鉴权必须解决的四个问题不只是“校验一下就放行”关于鉴权热搜词里出现了“鉴权绕过”“无限授权号鉴权系统”“使用 LLM 时如何防止密钥等信息泄露”。从这几个词就能看出大家关注的不是怎么发 token而是怎么防绕过、怎么管理大量授权号、怎么防止密钥在调用链中泄露。生产环境里鉴权要回答四个核心问题你是谁请求方是否持有合法凭证。你能不能调凭证的权限范围是否包含这个工具。你调了多少次限流、配额、计费都需要这一层。出了事能不能查审计日志里能不能还原完整的调用链。第一版的实现里我踩过一个坑只做了 API Key 对比没有做请求方身份和 Key 的绑定关系导致任何拿到 Key 的人都能以同一个身份调用所有工具。后来加上了“Key 属于哪个租户”的维度每个请求不仅要验证 Key还要验证 Key 对应的租户是否有权访问目标工具。2.2 API Key 设计与存储UUID 还是自定义格式API Key 的格式比很多人想象的重要。直接生成一个 UUID 当 Key 用虽然唯一性没问题但有两个隐患第一UUID 太常见第三方可以轻易识别出你的鉴权机制第二UUID 不含任何元信息出问题时排查不了是哪个租户在哪个时间段创建的 Key。我用的格式是mcp_前缀 租户 ID 片段 随机部分最终拼成一个 32 字节的字符串function generateApiKey(tenantId: string): string { const randomPart crypto.randomBytes(24).toString(base64url); const tenantPart Buffer.from(tenantId).toString(base64url).slice(0, 4); return mcp_${tenantPart}_${randomPart}; }Key 本身是明文生成一次然后只存哈希值。数据库里绝不存原始 Key而是存SHA-256(api_key)的哈希。为什么因为数据库一旦泄露原始 Key 如果直接躺里面等于所有授权号拱手送人。存哈希之后即使库被拖走攻击者拿到的也是不可逆的哈希值。校验的时候对请求头里的 Key 做同样的哈希再查库匹配。这里要注意一个实现细节比较哈希要用crypto.timingSafeEqual来做恒定时比较防止时序攻击——普通字符串比较在遇到不同长度的前缀时会在不同时间返回攻击者可以通过精确测量响应时间逐步推断出 Key 的每一位。2.3 中间件校验流程Header 解析 白名单 限流一步都不能少鉴权中间件按顺序处理六个环节顺序不能乱export async function authMiddleware(req: Request, res: Response, next: NextFunction) { // 1. 提取 Authorization Header const authHeader req.headers[authorization]; if (!authHeader || !authHeader.startsWith(Bearer )) { return res.status(401).json({ error: missing_credentials }); } // 2. 哈希并查库 const apiKey authHeader.slice(7); const keyHash crypto.createHash(sha256).update(apiKey).digest(hex); const keyRecord await apiKeyStore.findByHash(keyHash); if (!keyRecord) { return res.status(401).json({ error: invalid_api_key }); } // 3. 检查 Key 是否被吊销 if (keyRecord.revoked_at) { return res.status(403).json({ error: key_revoked }); } // 4. 检查租户状态 const tenant await tenantStore.findById(keyRecord.tenant_id); if (!tenant || tenant.status ! active) { return res.status(403).json({ error: tenant_disabled }); } // 5. 权限白名单校验 const toolAllowed await permissionStore.checkToolAccess( keyRecord.tenant_id, req.body?.params?.name ); if (!toolAllowed) { return res.status(403).json({ error: tool_not_allowed }); } // 6. 滑动窗口限流 const rateLimit await rateLimiter.check(keyRecord.tenant_id); if (!rateLimit.allowed) { return res.status(429).json({ error: rate_limited, retry_after: rateLimit.retryAfterSeconds }); } // 通过校验注入上下文 req.ctx { tenantId: keyRecord.tenant_id, keyId: keyRecord.id, scopes: keyRecord.scopes }; next(); }顺序为什么重要第一步如果 Header 缺失直接拒绝避免后续逻辑处理无效请求哈希查库放第二是因为数据库只认哈希这一步是前提吊销状态检查必须在权限检查之前一个已经吊销的 Key 不应该再消耗任何查询资源。最后才是限流因为限流计数器会和 Auth 状态联动——比如某个租户连续鉴权失败太多次还会触发额外的风控策略。2.4 HMAC 签名防止请求体被篡改而不只是防止身份伪装API Key 只能证明“你是你”但证明不了“你的请求没被篡改”。在网络链路中请求体有可能被中间人替换掉——密钥没错但调用的工具和参数被换成了恶意的。生产级鉴权必须加一层 HMAC 签名校验。实现逻辑是这样的客户端在请求头带上三个字段X-Timestamp、X-Nonce、X-Signature。签名字符串由tenant_id timestamp nonce 请求体哈希拼接后用密钥做 HMAC-SHA256。服务端校验顺序时间戳差值超过 300 秒直接拒绝防重放nonce 在 Redis 里查重防重放和重试攻击签名校验防篡改。function verifySignature(secret: string, payload: { timestamp: string; nonce: string; bodyHash: string; }): boolean { const baseString mcp_sign_v1:${payload.timestamp}:${payload.nonce}:${payload.bodyHash}; const expectedSig crypto .createHmac(sha256, secret) .update(baseString) .digest(base64); return timingSafeEqualStr(expectedSig, providedSig); }这里我用mcp_sign_v1作为版本前缀以后升级签名算法时可以通过前缀做平滑切换旧客户端不受影响。2.5 防止密钥泄露的实操细节LLM 场景尤其要注意关于“使用 LLM 时如何防止密钥等鉴权信息泄露”我的建议是三条第一MCP Server 的密钥只存在环境变量或专门的密钥管理服务里绝对不要写在代码仓库中。我用 dotenv 加载.env文件同时.env在.gitignore里强制排除。如果有条件用云厂商的 Secret Manager 或者 Vault 做动态注入。第二入参和出参的日志必须脱敏。很多工具函数会打印请求参数如果某个参数是个长字符串很可能里面嵌了密钥或者用户敏感信息。我在日志模块里写了一个 sanitizer递归检查对象里每个字符串字段如果匹配到常见密钥格式sk-开头的、Bearer开头的、长度超过 64 的随机字符串就替换成[REDACTED]。第三切分权限。不要给所有工具配同一个 Key。读类工具和写类工具分开授权密钥泄露时影响面可以从“全部工具”缩小到“单个工具组”。管理后台提供细粒度的 scope 配置比如tools:read、tools:write:calculator。3. 流式传输实战SSE 实现与长任务推送避坑3.1 从一次响应到多条消息客户端怎么就“流”起来了MCP 的流式传输一种常见实现是 SSE。SSE 看起来神秘原理其实特别简单HTTP 响应头里声明Content-Type: text/event-stream然后把连接保持打开服务端可以持续往响应体里写数据浏览器和 Node 客户端原生支持解析这种格式。MCP 场景里流式传输主要用于两种情况。一是工具执行时间较长需要逐步返回中间进度二是结果本身很大比如查询返回了几万行数据不能一次性序列化成 JSON 塞给客户端要分块传输。SSE 响应格式如下每条消息必须用data:前缀事件之间用空行分隔data: {type: progress, task_id: task_123, progress: 10} data: {type: progress, task_id: task_123, progress: 50} data: {type: result, task_id: task_123, result: {sum: 42}}这里有个关键点data:前面的冒号后面必须跟一个空格这是 SSE 的规范要求。如果不加空格有些客户端解析会失败我在调试时就栽过一次。另外每条消息必须以换行符结束消息与消息之间用空行分隔这些都是容易被忽略的细节。3.2 心跳机制没有心跳的 SSE 连接三十秒后就被切断SSE 默认不会自动断线但很多中间代理服务器Nginx、CDN有超时设置超过一定时间没有响应体数据就会断开连接。生产环境里长任务执行时间可能有几分钟甚至更久如果服务端不主动保持连接活跃客户端收不到任何数据连接就被代理切断了。解决办法是给 SSE 发送心跳注释。SSE 规范里以冒号开头的行是注释行客户端会自动忽略但网络层面它是有效数据能告知代理层“这个连接还活着”setInterval(() { if (response.writableEnded) { clearInterval(heartbeatTimer); return; } response.write(: heartbeat ${Date.now()}\n\n); }, 15000);心跳间隔我看实际情况定一般取代理超时时间的三分之一。Nginx 默认proxy_read_timeout是 60 秒所以我设 15 秒一次。每次心跳事件我在监听器里顺便清理已经写不完的连接避免资源泄漏。这里要特别提醒一个容易踩的坑不要用res.write()同时发送多路无关的事件流。SSE 连接和 MCP 会话是一一对应的如果有多个任务并发执行全部推到同一条 SSE 流里客户端无法区分消息归属。我的做法是每条消息都带task_id或者干脆一个任务开一条独立的 SSE 连接。3.3 任务异步化不要在请求回调里直接跑耗时逻辑刚开始写工具执行逻辑时我在 SSE 请求回调里同步执行计算任务。结果第一个测试用例就翻车了——一个耗时 30 秒的查询把整个 Node 进程的事件循环堵死了其他所有请求全部排队等待。正解是任务异步化。SSE 请求只负责开启事件流并立即返回耗时任务丢到异步执行器里跑执行完成后再往 SSE 流里写入结果。这个过程在 Node 里最简单的方式是结合stream和async控制大致结构如下app.get(/sse, authMiddleware, (req, res) { res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no }); const session sessionStore.create(req.ctx.tenantId); const streamId session.id; // 立即发送初始化就绪事件 res.write(data: ${JSON.stringify({ ready: true, streamId })}\n\n); // 订阅任务完成消息 taskEmitter.on(task:${streamId}, (payload) { res.write(data: ${JSON.stringify(payload)}\n\n); }); req.on(close, () { taskEmitter.removeAllListeners(task:${streamId}); sessionStore.release(streamId); }); });注意X-Accel-Buffering: no这个响应头。如果没有它Nginx 可能因为开启了缓冲而把 SSE 事件积压在内存里客户端迟迟收不到数据。这是 Nginx 代理场景下的高频问题我先写进代码里后面排查环节还会再提到。3.4 断线重连与续传方案客户端断开时服务端要做什么SSE 的优势是客户端断线后可以自动重连但重连之后状态怎么办这里必须做两个层面的处理。第一客户端侧SSE 的retry字段可以指定重连间隔。我通常在初始化握手时把这个字段设成 3000 毫秒让 EventSource 在断线后 3 秒重连。第二服务端侧必须在close事件里及时清理会话资源。如果不清理断线的会话会一直占着内存导致状态越来越膨胀。更严重的是客户端重连时会创建一个新 Session旧的 Session 如果还在执行任务结果会写入一个没有任何监听者的流数据悄悄丢失。解决数据丢的问题我在任务执行器里增加了一个映像检查任务完成后如果发现目标 stream 的监听者已经不在线就把结果写入持久化存储等客户端带着相同的session_id重新建立连接时主动把离线期间产生的所有消息补偿推送给它。这部分逻辑放在状态管理模块里后面详细展开。4. 状态管理实战会话恢复、进度追踪与多租户隔离4.1 状态管理到底是什么以及“对话状态管理”的两种实现方向状态管理是“对话状态管理”的核心支撑。大模型应用调用工具时通常不是单次请求就完了而是一个多轮对话中反复调用多个工具。每次调用的参数可能依赖上一次调用的结果。如果服务端不保存任何状态客户端每次都得把完整上下文重发一遍既浪费 token又容易出错。状态管理有两种实现方向无状态和有状态各有各的适用场景。无状态方案每次请求都携带完整的上下文服务端不存任何跨请求信息。优点是服务端实现简单、天然支持横向扩展缺点是客户端要维护全部状态链路且大模型上下文越长token 消耗越高。有状态方案服务端维护会话 ID 到状态数据的映射客户端只需携带会话 ID服务端从存储中恢复该会话的完整状态。优点是对客户端更友好、减少重复数据难点是状态的一致性和持久化。我的选择是混合方案会话的基础信息工具列表、初始化能力、租户标识服务端保存对话的详细上下文由客户端决定是否需要服务端暂存通过一个可选参数preserve_context来控制。4.2 会话生命周期管理创建、保持、失效的完整流程会话生命周期从初始化握手开始。MCP 协议的initialize请求会携带protocolVersion、capabilities、clientInfo等信息。服务端处理这个请求时同时完成会话创建async function handleInitialize(req: McpRequest, ctx: RequestContext) { const protocolVersion req.params.protocolVersion; // 校验协议版本兼容性 if (!SUPPORTED_VERSIONS.includes(protocolVersion)) { return error(ErrorCodes.INVALID_PARAMS, unsupported protocol version); } // 创建会话 const session await sessionStore.create({ tenantId: ctx.tenantId, clientInfo: req.params.clientInfo, capabilities: req.params.capabilities, refreshToken: crypto.randomBytes(32).toString(base64url) }); return { protocolVersion, capabilities: { streaming: true, statePersistence: true }, serverInfo, sessionId: session.id, expiresIn: SESSION_TTL_SECONDS }; }这里生成的session_id是后续所有请求和 SSE 流的关联凭证。会话必须有过期时间我最初设置的 TTL 是 30 分钟但实测中发现业务场景经常有超过 1 小时的并发任务于是改成可配置的默认 2 小时每次有活动时自动续期。会话失效条件有三个显式关闭客户端发close请求、过期自动失效、强制注销管理员踢人。每次失效都要触发状态清理和审计日志记录不然会留下不可控的僵尸会话。4.3 跨请求的状态持久化Redis 存储设计与序列化策略内存态只适合单机调试生产环境必须做分布式状态存储。我是用 Redis 作为主存储核心原因有两点支持 TTL 自动过期不用自己写定时清理任务支持原子操作多个实例并发更新同一个会话状态时不容易产生竞态条件。Redis 里的 Key 设计如下mcp:session:{session_id} - Hash存会话基础信息 mcp:context:{session_id} - JSON 字符串存对话上下文快照 mcp:session:{session_id}:tasks - Set存该会话关联的任务 ID 列表存储的序列化策略有一个经验教训不要把整个上下文对象直接用一个 Key 存。上下文可能包含正在执行中的任务进度、已经完成的历史结果、临时变量等频繁更新时整个读写都锁在一个 Key 上性能堪忧。我后来拆成两部分静态信息会话建立时写入后续很少变和动态信息任务进度、临时结果更新频繁。静态部分用 Hash动态部分用字符串各存各的 Key。async function updateContext(sessionId: string, delta: PartialSessionContext) { const key mcp:context:${sessionId}; // 读-改-写最好用 Lua 脚本保证原子性 const prev await redis.get(key); const next { ...JSON.parse(prev || {}), ...delta }; await redis.set(key, JSON.stringify(next), EX, SESSION_TTL_SECONDS); }每次更新上下文都重新设置 TTL实现“有活动就不过期”的语义。这里要注意的是读-改-写在并发下可能存在覆盖问题。如果同一个会话同时有多个工具在更新状态用简单 set 会导致最后写入覆盖先写入的字段。我在关键路径上用了 Lua 脚本保证读改写原子性这个细节别看它小线上并发一高就问题非常明显。4.4 状态恢复与断线续传离线任务结果不丢失的完整实现前面提到断线重连后要补偿推送离线期间产生的消息这里给出具体的状态机设计方案。我把每一次工具调用建模为一个 TaskTask 状态机包含五个状态PENDING排队中 RUNNING执行中 COMPLETED已完成 FAILED执行失败 CANCELED已取消SSE 连接断开时如果 Task 还在 RUNNING服务端不会中断任务执行。任务正常完成后结果写入持久化存储中的pending_events队列并设置标记等待会话恢复。客户端重新连接时带上了同一个session_id服务端就能从pending_events里把消息全部捞出来逐条推送给客户端。async function resumeSession(sessionId: string, res: ServerResponse) { const pendingEvents await eventStore.popPending(sessionId); for (const event of pendingEvents) { res.write(data: ${JSON.stringify(event)}\n\n); } // 推送完后清理分页游标 await eventStore.clearPending(sessionId); }为防推送过程中连接再次断开pending_events里每一条消息都带一个自增序号客户端收到后回传ack_seq。服务端只删除已确认序号之前的消息没确认的留着下次再推。这其实就是消息队列的至少一次投递语义MCP 场景下足够用不需要做到精确一次。4.5 多租户隔离状态 Key 设计中的租户维度防串数据事故多租户隔离是个一不留神就会出大事的环节。最初我只用session_id做 Redis Key没有把租户维度加进去。后来做安全评审时发现一个严重隐患如果两个租户生成的session_id发生碰撞或者某个客户端传入了一个它自己推断出的 session_id就可能访问到另一个租户的会话状态。修复方案是在所有 Redis Key 中加入租户 ID 作为前缀mcp:{tenant_id}:session:{session_id} mcp:{tenant_id}:context:{session_id}同时在后端代码里进行一次强制校验每次请求携带的session_id对应的租户 ID必须和当前鉴权上下文的租户 ID 一致不一致直接返回403 SESSION_BELONGS_TO_OTHER_TENANT。另外还做了缓存隔离。每个租户的状态缓存独立命名空间避免内存缓存里两个租户的数据互相污染。这些设计从架构上杜绝了状态串数据的问题而不是靠程序员的自觉来保证。4.6 MCP Server 的自定义日志管理与审计链路“MCP Server 端的日志如何使用自定义日志管理”这个热搜词命中了一个很实在的需求。生产环境里的日志不是打印几行字那么简单要能把一次请求从入口到工具执行再到响应推送完整串起来。我实现了一套自定义日志管理器核心思路是创建带 requestId 的异步上下文。从请求进入中间件开始生成request_id后续所有日志都携带这个 ID包括工具执行、状态读写、任务调度。查询审计日志时只要输入一个 request_id就能还原这次调用的完整链条。const logger { info: (event: string, meta?: Recordstring, unknown) { const context asyncLocalStorage.getStore(); const base { ts: new Date().toISOString(), event, request_id: context?.requestId, session_id: context?.sessionId, tenant_id: context?.tenantId }; console.log(JSON.stringify({ ...base, ...meta })); } };这里用了AsyncLocalStorageNode.js 官方提供的异步上下文跟踪机制。它能保证即使代码切换到下一个微任务上下文也不会丢失。输出格式统一为 JSON 行这样可以直接接入 ELK 或 Loki 做日志检索。审计日志的关键记录点鉴权成功和失败事件失败要记录失败原因Key 无效、不在白名单、限流工具调用开始和结束带耗时状态变更事件比如 Session 创建/续期/销毁流式连接建立/断开事件其中鉴权失败日志特别要记全因为它是排查安全攻击的第一手资料。我记录 client IP、请求路径、ban 的原因、请求的 Authorization Header 前几位脱敏之后但绝不记录完整的 Key。5. 生产环境部署与排查实录常见问题速查5.1 鉴权相关的高频问题以及如何防止“无限授权号”泛滥鉴权问题的排查通常一眼就能看到异常但根因往往比较隐蔽。我整理了实际运维中高频出现的问题症状可能原因排查方法同一 Key 并发请求大量 401安全组/防火场拦截了部分来源 IP导致 IP 变化检查客户端前后 IP 是否一致考虑用 API Key IP 绑定一个租户生成了上千个 Key管理后台没有防重机制限制每个租户的 Key 数量上限超出要审批某个 Key 被调用上千次但在数据库里看不到记录查询缓存层把 Key 哈希结果缓存了且未设置过期缓存 Key 哈希时必须关联租户状态状态变更后强制清除缓存恶意请求带着伪造 Key 高频探测缺少预热和风控逻辑增加全局维度限流同一 IP 连续鉴权失败超 N 次封禁 IP 一小时“无限授权号”这个词其实反映的是一种滥用隐患。防止无限授权号最直接的手段是租户维度的配额控制。我用 Redis 的 INCR 做计数器设置api_key_quota:{tenant_id}初始值由订阅计划决定每次创建新 Key 时检查剩余配额用完之后必须删掉旧 Key 才能创建新 Key。5.2 SSE 连接不吐数据、断流、重复推送的排查思路SSE 是最容易出现“客户端明明连上了但迟迟不收到数据”这种诡异问题的地方。我的排查顺序固定如下第一步确认响应头。用curl -N -H Accept: text/event-stream直接测试看看返回的响应头里有没有Content-Type: text/event-stream如果没有说明中间层重写了响应头。第二步确认代理缓冲。如果走 Nginx 反代检查配置里有没有proxy_buffering off; proxy_cache off;以及是否添加了X-Accel-Buffering: no。Nginx 默认开启缓冲会攒满一个 TCP 段才转发小数据量事件流就会被吞掉这是最常见的“连上但不吐数据”原因。第三步确认心跳在正常工作。看服务端日志里心跳是否持续打印如果日志断档说明服务端进程卡死或者响应流已经被关闭。响应流关闭后继续write会在某些 Node 版本里抛出ERR_STREAM_WRITE_AFTER_END错误必须捕获并停止写入。重复推送的问题通常和pending_events清理策略有关。消息推送成功后客户端没来得及回ack_seq服务端就不会删除这条消息重连时又被推了一遍。解决方式有两种客户端通过dedup_cache按 event_id 去重或者服务端等一整个批次都确认后再批量删除把“重复”限制在一个批次内而不是无限重试。5.3 状态管理的问题内存暴涨、会话过期误杀、恢复的不一致状态管理方向我遇到过三类问题每一类都值得提前设防。第一类是内存暴涨。如果直接开了memory类型的状态存储没有设置 TTL或者 TTL 冲刷不彻底时间一长内存就会被空闲会话占满。我的做法是强制所有状态存储实现必须支持 TTL并且内存实现里加一个定期巡检器每 60 秒清理一批过期 Key。第二类是会话过期误杀。有时候用户任务重两个请求之间间隔超过了 TTL会话被清了用户需要重新初始化握手。这个体验不算好。我增加了一个“宽限期”设置会话过期后不立即删除底层状态而是在 Redis 里保留一个retained标记 5 分钟。如果这个窗口内用户回来直接恢复上下文重新续期超过窗口才彻底清理。第三类是恢复不一致。断线重连后状态没有完全恢复比如任务已经执行完成但客户端不知道。这个问题的根因通常是任务完成的回写没有和 SSE 重连流程做同步。我加了一个两阶段提交式的恢复逻辑先恢复会话元数据再推送 pending events全部推送完毕并确认后才把任务状态从未同步改成已同步。5.4 日志不打印、输出格式乱、请求追踪断链的实操解法自定义日志管理器上线之后连续遇到过三个让人抓狂的小问题。一是日志不打印。查了半天发现是异步日志处理器内部抛异常后静默吞掉了。我处理日志的 pipeline 里有些逻辑会做字段映射和脱敏脱敏函数如果遇到非字符串类型会抛错。解决方案是给日志输出函数最外层包一个try/catch外层保证日志框架本身永远不抛异常内层先对数据进行类型归一化再脱敏。二是输出格式乱。多个异步任务同时打日志如果直接 console.log 长字符串可能被事件循环打断导致格式错乱。统一改成 JSON.stringify 成单行输出避免多行日志被交错拼接。单行 JSON 日志的解析成本也低各个日志平台都原生支持。三是请求追踪断链。中间件生成了 request_id但工具层异步执行的时候拿不到这个 ID。这是 AsyncLocalStorage 最坑的点——如果某些异步库没绑定执行上下文ID 就丢了。解决办法是在任务投递到异步队列时显式把 request_id 作为 payload 的一个字段传下去任务执行完成回写日志时再把它取出来。这比依赖 AsyncLocalStorage 在复杂执行链中不丢上下文要可靠得多。6. 压测数据、效果评估与本项目可以复用的沉淀6.1 本项目的压测数据结构并发、吞吐和断线恢复的量化表现项目上线前做了一轮基准压测数据能直观反映架构的选型是否合格。我压测的机器是 4 核 8G 的云服务器单个 Node.js 实例Redis 和数据库都在独立实例上。纯鉴权接口压测1000 并发API Key 校验 限流检查P95 延迟 45msP99 延迟 82msSSE 初始化200 并发建立 SSE 连接P95 握手时间 120ms工具执行模拟 100ms CPU 计算500 请求并发执行P95 延迟 210ms断线恢复模拟 50 个会话在任务执行中随机断开然后在 3 秒后重连恢复成功率 100%消息补偿数量准确率 99.6%个别消息因客户端主动关闭后未回 ack 而重推可接受整体来看状态存储和鉴权模块没有成为瓶颈SSE 连接数的增加是主要资源消耗点。连接数量超过 2000 时需要扩展实例并进行横向扩容单实例扛长期 2000 并发连接会显得吃力。6.2 鉴权模块、流式传输、状态管理三层可以分别复用到什么场景这个项目虽然是围绕 MCP 展开的但三层架构的设计思路可以独立复用。鉴权模块可以抽成一个通用 API 网关的鉴权组件任何 HTTP 服务想要实现 API Key HMAC 签名 限流直接把这套逻辑搬过去改改配置就能用。尤其是“Key 只存哈希 恒定时比较 租户配额”这套组合拳在防止鉴权绕过和异常授权方面非常有效。流式传输模块的 SSE 加心跳加异步任务模式适合所有“长耗时任务 实时进度推送”的场景比如批量数据处理、视频转码任务等不需要绑定 MCP 协议。断线补偿的思路也可以直接移植到一般消息推送系统中去。状态管理模块的按任务状态机建模、跨租户隔离、TTL 自动续期可以复用到任何需要“会话化”的业务接口比如聊天机器人状态管理、CI/CD 任务状态跟踪。6.3 如果基于本项目继续扩展有哪些值得做的方向这个项目我目前用着顺手但如果继续迭代我认为有三个方向非常值得扩展。第一个方向是接入更多传输层不只是 SSE。比如为局域网或内网场景实现 WebSocket 传输层让不支持 SSE 的客户端也能接入。按现有分层传输层是独立的加一个transport/websocket.ts就行核心的协议处理、鉴权、状态管理几乎不用改。第二个方向是增加 gRPC 作为工具执行的内部通道。当工具执行往往需要调用微服务时用 gRPC 做内部调用链路的通信协议比 HTTP 内网调用吞吐量高也方便引入分布式链路追踪。第三个方向是完善自动限流和风控策略。目前限流是固定窗口计数器对于突刺特明显的调用方可能会误伤。将来可以升级成基于令牌桶的算法配合滑动日志让正常业务的高峰流量不被误限而恶意调用在早期就被挤压到阈值之外。以上三点如果做了整个 Server 的适用面会比单纯作为 MCP 工具协议接口广得多。写到这里我想起实际开发中最深的感受。从零手写一个生产级 MCP Server最难的部分其实不是协议本身而是把鉴权、流式和状态管理这些横切关注点织进同一个体系。协议文档只会告诉你“应该怎么做”但不会告诉你“在不这么做的时候崩在哪里”。只要把这些问题在架构设计阶段想清楚把三层边界划明白后面的开发就像往搭好的架子上填砖。这个项目后续无论接入新工具还是扩展新的传输方式我相信这套底子都撑得住。