
说个实在的这两年MCP Server从“给AI模型加个小工具”演变成真正的生产级服务中间差的不是协议理解而是工程素养。很多人本地demo跑得飞起一旦要部署到线上就发现鉴权、流式传输、状态管理这三个坑哪个都没踩明白。我最近正好把团队的一个内部数据查询服务改造成MCP Server推上生产整个过程中把鉴权、流式传输和状态管理从0到1完整走了一遍。这篇文章就把我在这个过程里踩过的坑、做过的取舍和最终落地的方案原原本本写出来希望对想把手写MCP Server推上生产的同学有帮助。不管你是用Node.js、Python还是Go核心思路都是通用的照着这个思路走能少走不少弯路。1. 先想清楚你要写的是一个什么形态的MCP Server1.1 从demo到生产的差距不在功能在边界本地demo的MCP Server通常长这样实现一个tools/list再实现一个tools/call把几个内部函数包装一下跑起来给Claude或Cursor用完事。但一旦要把它放到生产环境里问题就全冒出来了谁能调这个服务调用者的Token丢了怎么办一次工具调用要执行好几秒客户端怎么优雅地接收结果多轮对话里上一轮查到的订单号下一轮怎么还接得上这些问题没有一个在MCP协议文档里有现成答案每一条都需要自己动手设计。说白了从demo到生产的质变是从“写功能”变成“画边界”。鉴权是安全边界流式传输是通信边界状态管理是连续性边界。这三个边界画清楚了你的MCP Server才算真正有生产级的骨架。1.2 整体架构和模块划分我最终落地的架构把MCP Server分成五层每层各管一件事传输层负责和客户端建立连接支持stdio和Streamable HTTP两种模式协议层处理JSON-RPC 2.0的封装、解析、错误码映射鉴权层统一拦截所有入站请求校验身份、权限、限流状态层管理会话生命周期、上下文存储、过期清理业务层真正执行工具逻辑对接内部服务技术选型上我用的Node.js TypeScript配合官方SDK里的协议类型定义但没有完全依赖SDK的高级封装因为生产环境需要精细控制传输和鉴权细节SDK封装越厚越难插自己的中间件。SDK拿来当协议解析库用传输和鉴权自己写这是我在实际项目中觉得最舒服的姿势。2. 鉴权是第一道门也是翻车率最高的地方2.1 先搞清楚你的传输方式决定了鉴权的必要性很多人一上来就问“MCP Server要不要做鉴权”这个问题其实没法一句话回答得先看传输方式。如果你走的是stdio模式MCP Server是作为子进程被客户端拉起来的调用方本身就是本机进程身份已经被父进程的启动机制约束住了服务端再做一层鉴权意义不大属于防御纵深里的可选环节不是必须。但一旦走了HTTP模式情况就完全变了。你的MCP Server暴露成一个网络服务任何人都可以往这个端口发JSON-RPC消息。这时候不做鉴权就相当于把公司数据库的查询接口裸奔在公网上。MCP官方对远程服务的推荐方案是OAuth 2.1我实际落地时用了更务实的方案Bearer TokenJWT 细粒度权限白名单。如果你的服务要对外开放、要兼容多个第三方客户端建议直接按官方推荐走OAuth 2.1协议流程会更标准。2.2 三个经典的鉴权翻车现场比协议文档值钱第一个坑也是最容易致命的只在initialize握手时校验Token后续的tools/call、resources/read等请求直接放行。我给一个朋友看代码时发现他写了这样的逻辑在initialize方法里检查了一下Authorization头验证通过之后就把这个连接标记为“已认证”之后所有消息都跳过校验。这在实际生产里就是致命的鉴权绕过漏洞。MCP的JSON-RPC消息是无状态的每个请求都有可能是新的人发来的。解决方案很简单但必须坚决全局中间件统一拦截所有HTTP请求每一次JSON-RPC调用都做完整的Token校验、权限校验、限流校验没有例外。第二个坑Token永远不过期。我见过有人直接生成一个固定字符串当共享密钥写死在配置文件里美其名曰“内部使用方便运维”。这在安全领域有个专门的吐槽词叫“无限授权”一旦这个字符串泄露你连撤销的手段都没有只能改配置重启服务。正确做法是JWT设置有效期access token 2小时refresh token 30天配合refresh token轮换机制让长期有效的凭证可撤销、可续期。第三个坑权限粒度太粗。一个Token能调所有工具。生产环境里一个运营同学的工具Token不应该能调管理员的维护接口。我在鉴权层做了一张工具白名单Token签发时就绑定允许调用的工具列表业务层执行前再校验一次双重检查避免越权。2.3 可落地的鉴权中间件直接参考下面这段是我在Express里写的鉴权中间件逻辑顺序很关键import jwt from jsonwebtoken; async function authMiddleware(req, res, next) { // 1. 从 Authorization 头解析 Bearer Token const token req.headers.authorization?.replace(/^Bearer /, ); if (!token) { return res.status(401).json({ error: missing_token }); } try { // 2. 验证签名与过期时间注意显式指定算法防止算法混淆攻击 const payload jwt.verify(token, process.env.JWT_SECRET, { algorithms: [HS256], }); // 3. 检查 Token 是否在撤销列表中 if (await isTokenRevoked(payload.jti)) { return res.status(401).json({ error: token_revoked }); } // 4. 将身份和权限白名单绑定到请求上下文 req.user { id: payload.sub, tools: payload.tools ?? [], resources: payload.resources ?? [], }; next(); } catch (err) { // 统一返回401不要向客户端透露具体的校验失败原因 return res.status(401).json({ error: invalid_token }); } }这套中间件挂在所有MCP相关的路由之前尤其要覆盖/mcp这个核心入口。另外提一下Token的签名密钥不要写死在代码里更不要提交到Git仓库里从环境变量或配置中心读取部署时注入。2.4 密钥管理与配置中心的联动说到密钥管理我提一个其他组件里非常常见的思路像Nacos这类配置中心开启鉴权本质上是保护配置项的读写权限。MCP Server的密钥管理也是同一个道理。Token签名密钥、Redis连接密码、数据库凭证这些信息应该集中放在配置中心或密钥管理服务里而不是散落在各个服务代码中。我在生产环境就是这么干的服务启动时从配置中心读取JWT_SECRET如果读取失败直接拒绝启动保证服务不会带着默认密钥裸奔。3. 流式传输别让一次工具调用把连接掐死3.1 MCP的传输协议演进你该选哪套MCP协议一开始主要走stdio客户端与Server之间通过标准输入输出传JSON-RPC消息。这种方式在本地开发调试很舒服但放到远程服务器上就不行了总不能让生产环境的AI客户端去服务器上拉一个子进程吧。后来官方推出了Streamable HTTP Transport基于SSEServer-Sent Events做服务端推送。客户端发一个POST请求到MCP ServerServer处理完把结果通过SSE流推回来。这个模式对远程部署非常友好IDE插件、Web应用、云端服务都能直接连。为什么SSE而不是WebSocketSSE的优势在于它是单向的、基于HTTP的天然能穿透大部分代理和防火墙部署成本低。MCP里工具的调用基本上都是客户端发起、服务端响应服务端主动推送的场景很少SSE足够用了。如果将来真需要双向实时通信再考虑升级到WebSocket也不迟。3.2 服务端SSE推送的关键细节SSE的实现看似简单就是设置几个header然后往响应流里写数据但生产环境里有几个细节必须处理好Content-Type必须是text/event-stream这是客户端识别SSE的硬性标准Cache-Control设为no-cache避免中间代理缓存响应流事件的格式严格遵循event: xxx\ndata: {...}\n\n多个字段之间用空行分隔心跳机制如果一次工具调用耗时很长要定期发送注释行以冒号开头或ping事件防止中间网关断掉空闲连接还有一个特别容易踩的坑如果MCP Server部署在Nginx后面SSE的流式响应会被Nginx的缓冲机制吞掉。默认情况下Nginx会把上游响应缓冲到一定大小再发给客户端这就导致客户端迟迟收不到数据表现为工具调用了但结果一直没回来。解决方法是给MCP的location关闭缓冲location /mcp { proxy_pass http://mcp_server_backend; proxy_buffering off; proxy_cache off; proxy_set_header Connection ; proxy_http_version 1.1; chunked_transfer_encoding off; proxy_read_timeout 3600s; }3.3 一次MCP调用在Streamable HTTP下的完整流程我先用文字把流程讲清楚再给代码。假设客户端要调用一个query_order工具客户端向/mcp发送POST请求body是JSON-RPC格式的tools/call消息Header带Bearer TokenServer鉴权通过后业务层执行查询逻辑结果通过SSE事件message推回客户端客户端收到事件后解析data字段得到JSON-RPC响应下面是我写的处理SSE的核心逻辑省略了业务代码只保留传输骨架app.post(/mcp, authMiddleware, async (req, res) { // 设置SSE响应头 res.setHeader(Content-Type, text/event-stream); res.setHeader(Cache-Control, no-cache); res.setHeader(Connection, keep-alive); res.flushHeaders(); try { const message req.body; // JSON-RPC 2.0 消息 const result await handleMcpMessage(message, req.user); res.write(event: message\n); res.write(data: ${JSON.stringify(result)}\n\n); } catch (err) { res.write(event: error\n); res.write( data: ${JSON.stringify({ jsonrpc: 2.0, id: req.body?.id ?? null, error: { code: -32603, message: internal_error }, })}\n\n ); } finally { res.end(); } });这个写法是“单请求单响应”模式适合大多数场景。如果你的工具调用时间很长或者要支持服务端主动推送进度通知比如分阶段更新任务状态那就需要保持SSE连接不关闭持续推送多个事件。这种场景下记得要在客户端断开时清理服务端的资源通过监听关闭事件处理req.on(close, () { // 清理业务层的异步任务、释放连接资源 cancelOngoingTask(req.requestId); });3.4 调试流式传输的实操心得调SSE最建议的方式是用curl直接看原始响应别一上来就用客户端调试工具包了一层反而看不清问题curl -N -X POST http://your-server/mcp \ -H Content-Type: application/json \ -H Authorization: Bearer token \ -d {jsonrpc:2.0,id:1,method:tools/call,params:{name:query_order,arguments:{orderId:2024001}}}-N参数是--no-buffer让curl不要缓冲输出直接打印流式数据。看到形如event: message和data: {...}的输出基本就说明SSE通路是通的。如果只看到headers没有body八成是Nginx缓冲没关。如果连接直接断掉看一下是不是超时时间设太短。4. 状态管理让多轮对话真正接得上4.1 没有状态管理的MCP Server只是无情的函数执行器MCP Server如果只是每次无脑执行一个工具调用那确实不需要状态管理。但生产场景几乎都是多轮对话用户先问“帮我查一下订单2024001”AI调工具得到订单信息用户接着说“这个订单的物流到哪里了”AI需要知道订单号还是2024001。问题来了订单号这个上下文存在哪如果客户端每次都把完整上下文塞进prompt里token消耗巨大而且容易截断。如果服务端不保存状态第二轮的“这个订单”就成了无源之水。所以生产级MCP Server必须有一个服务端状态层保存会话期间的上下文信息。4.2 状态存储选型从内存到Redis选型原则很简单单实例用内存多实例用Redis需要审计追踪上数据库。单实例部署的场景一个Map加上TTL就能解决。但生产环境很少有单实例跑到底的多实例部署时用户第一次请求打到实例A第二次打到实例B如果状态只在内存里会话就丢了。这种场景必须用Redis这类外部存储。还有一个容易被忽略的需求敏感数据隔离。你的状态里可能存了用户的Token、查询参数、业务数据Redis虽然内网部署但也要做好访问控制密码不要用默认的网络层面做最小权限隔离。我在Redis里存储时还会把敏感的字段脱敏后再存状态层只保留业务诉求不保留敏感明文。4.3 设计一个会话管理模块我最终落地的会话管理模块分三块创建会话、更新会话、清理会话。先看创建和存储的代码import { createClient } from redis; import crypto from node:crypto; const redis createClient({ url: process.env.REDIS_URL }); await redis.connect(); export async function createSession(user, clientInfo) { const sessionId crypto.randomUUID(); // 用UUID v4即可无需全局递增 const sessionData { userId: user.id, clientInfo, createdAt: Date.now(), lastActiveAt: Date.now(), context: {}, // 按工具维度保存上下文 }; await redis.set(mcp:session:${sessionId}, JSON.stringify(sessionData), { EX: 3600, // 1小时无活动自动过期 }); return sessionId; }更新上下文时要注意并发问题。同一个会话可能有多个工具调用并发执行同时更新同一个key会互相覆盖。我用Redis的JSON数据结构或hash来存储context的各个字段更新时使用HSET而不是GET后再SET保证原子性。会话过期清理也不只是依赖Redis的EX过期我会另外跑一个定时任务把超过24小时的非活跃会话彻底删除释放存储空间。4.4 状态管理的几个真正考点会话归属。这是安全里非常关键的一环。用户在会话里存了自己的查询记录另一个用户绝对不能通过猜测或篡改sessionId来读取。我的做法是Redis的key用sessionId但value里存userId每次读取时校验session里的userId和当前登录人是否一致不一致直接拒绝。上下文爆炸。多轮对话越聊越长context里的数据越积越多。如果不设上限一个活跃会话能吃掉几个GB的Redis内存。我在context写入时就做了裁剪策略单个key只保留最近5条记录超出部分从最老的开始淘汰。会话恢复。客户端拿着sessionId来请求但Redis里数据已经过期了怎么办我的策略是返回一个明确的会话失效错误码让客户端走重新初始化流程而不是报一个模糊的内部错误。5. 日志、监控与自定义日志管理5.1 为什么默认日志不够用很多用官方SDK的人会发现SDK自带的日志要么太啰嗦要么缺关键字段排查问题时根本不够用。你要的是“谁在什么时间通过哪个会话调用了哪个工具耗时多少结果如何”默认日志往往给不了这些。生产级MCP Server需要结构化日志每一项都是JSON格式方便采集到日志平台里做检索和告警。我自己用Pino做的日志改造核心是给每条日志打上请求链路信息。5.2 结构化日志的落地方案先看一段日志中间件的核心逻辑import pino from pino; const logger pino({ level: process.env.LOG_LEVEL || info, timestamp: pino.stdTimeFunctions.isoTime, formatters: { level: (label) ({ level: label }), }, }); app.use((req, res, next) { req.logger logger.child({ requestId: crypto.randomUUID(), sessionId: req.headers[mcp-session-id], userId: req.user?.id, }); const start Date.now(); res.on(finish, () { req.logger.info( { path: req.path, method: req.method, durationMs: Date.now() - start, statusCode: res.statusCode, }, request completed ); }); next(); });每条日志都带requestId、sessionId、userId查问题的时候就能按会话拉出一整条链路。工具调用级别的日志单独记录包含工具名、入参简况、耗时、返回状态。特别注意脱敏。工具调用入参里很可能带着密钥、身份证号、手机号这类敏感信息。我的做法是打日志之前对参数做一次脱敏处理只保留字段名和字段的哈希值不记录原始明文。这个习惯被坑过几次之后养成的别等出事再后悔。5.3 错误码规范化MCP底层是JSON-RPC 2.0错误码有一套标准定义-32700解析错误JSON格式不对-32600无效请求-32601方法不存在-32602无效参数-32603内部错误-32000到-32099服务端自定义错误我要求所有工具调用返回的业务错误都映射到这套标准上不要自己发明一套错误码体系。客户端尤其是各种AI IDE对JSON-RPC标准错误码有默认处理逻辑违反标准会导致客户端显示一堆莫名其妙的错误信息。5.4 安全场景的额外防护社区里现在已经有人把安全测试工具接到MCP上让AI直接操控Burp Suite这类工具去做接口安全测试这在本地调试场景很有价值。但当你的MCP Server暴露在网络上时就可能被自动化工具扫描、探测。所以我在生产环境额外加了两个防护一是接口限流按用户维度限制每分钟最大请求数超出返回429二是敏感操作审计所有删除、修改、配置类的工具调用除了记录日志还要发送告警。这不是什么新发明但很多MCP Server的实现里根本没有这两条。6. 常见问题与排查技巧实录6.1 鉴权相关为什么测试的时候鉴权“好像被绕过了”排查思路是先确认是不是只在握手阶段做了校验然后把每个请求都打一条日志看中间件是否真的执行了Token校验。这种情况十有八九是中间件只挂在了某个特定路由上而实际调用的路由绕过了它。客户端报401但浏览器里请求明明带了Token大概率是Authorization头的格式问题。有些客户端SDK默认不带Bearer前缀或者用了小写的bearer服务端解析时没做兼容。我的解析逻辑里直接replace(/^Bearer /i, )大小写通吃。6.2 流式传输相关Nginx之后SSE数据一直不返回99%是缓冲没关。除了配置proxy_buffering off之外还要检查有没有其他中间层比如CDN、API网关也做了缓冲。客户端重连后之前的工具调用结果丢了SSE连接断开后如果业务逻辑没有实现幂等恢复客户端重连后是无法知道上次调用的结果的。我的做法是给每次长耗时调用生成一个taskId客户端重连后可以用taskId查询执行状态。6.3 状态管理相关Redis重启后所有会话都断了用户被踢下线Redis默认不持久化重启丢数据是正常的。如果业务不能容忍开启AOF持久化或者干脆用带有持久化能力的云数据库。在这个问题上很多人在内存和Redis之间做选择时容易忽略Redis的持久化配置。多轮对话串了上下文A用户的问题跑到了B用户那里基本可以断定是会话ID设计有缺陷可能是用全局递增ID而不是随机UUID导致可预测。也可能是读取状态时没有校验会话归属的userId只校验了sessionId本身。把这两个地方都查一遍。6.4 日志相关日志没输出或者重复输出前者通常是日志级别配错了排查时先看Pino的level配置和实际输出环境变量是否覆盖了它。后者通常是SDK默认日志器和自己接入的日志器同时在工作要在SDK初始化时把内置的日志传输关掉只保留自定义的那一路。日志里全是敏感信息立刻检查打日志的地方有没有做脱敏有没有把原始请求参数直接扔进去。这个没有捷径只能逐条审视所有info、debug的调用点把敏感字段替换成哈希值。7. 最后的操作体会先说我个人的结论手写一个生产级MCP Server真正的难点从来不在MCP协议本身协议文档翻一遍就能懂。难的是把鉴权、流式传输、状态管理这些“非功能性需求”做成工程上可靠、运维上可控的样子。我在实际推进过程中最深的体会是一定要把鉴权中间件放到最前面来做不要等工具实现完了再补。者们真的很一致先画边界再写业务。鉴权、状态、日志这三层其实就是边界的具体表现。先把边界立住了后面加工具只是往里填东西而已。最后再分享一个小技巧上线前用真实客户端连一通过程中打开服务器端的结构化日志一条一条看请求链路是否顺畅。这一步特别能发现测试脚本发现不了的问题像是客户端实际发的协议版本、鉴权头的写法、会话恢复的时序这些都是联调才能暴露出来的。如果后面你的MCP Server开始承担更重的职责还可以继续往多租户隔离、配额计费、可观测性集成这几个方向扩展。不过那都是后话了先把这一轮的设计和实现做实生产级的底子自然就打好了。