
接到这个需求的时候我其实有点犹豫市面上已经有官方SDK套一层包装似乎就能交付。但当真正要落地一个生产级 MCP Server 的时候问题一下子变得具体起来——鉴权怎么设计才不会被绕过流式传输在真实网络环境里怎么保证不丢数据状态管理怎么做才能支撑多租户和断线重连。这些恰恰是官方示例里最少提及的部分。这篇文章不是协议文档的复述而是我从零手写一个生产级 MCP ServerModel Context Protocol Server的完整记录。你会看到我为什么放弃现成 SDK也会看到鉴权、流式传输、状态管理三个核心模块在真实场景里的设计取舍和踩坑过程。适合正准备接入 MCP 的 AI 应用开发者、想把内部工具暴露给 AI IDE比如 Trae 这类产品的团队以及对协议层实现感兴趣的读者。1. 为什么放着官方SDK不用非要自己手写一个MCP Server1.1 官方SDK的边界与生产环境的真实需求先明确一个事实MCP 官方 SDK 解决的是协议通信问题它封装了 JSON-RPC 2.0 的消息编解码、stdio 和 SSE 两种传输层的建立以及基础的 client/server 生命周期处理。如果你只是写一个 Demo给 AI 工具暴露一两个查询函数官方 SDK 完全够用半小时就能跑通。但生产级是另一套标准。我把实际需求拆开来看官方 SDK 的短板就暴露了。第一鉴权是一个空架子——SDK 不会帮你决定 Token 怎么签发、怎么轮换、客户端能调用哪些工具第二日志策略完全缺失——默认输出是控制台打印流没有结构化字段也没有 traceId 贯穿链路出了问题很难定位第三会话状态管理基本要业务自己实现SDK 只保证底层连接不保证业务语义上的会话连续性第四流式传输在资源竞争和客户端断连场景下没有背压backpressure控制数据堆积起来只能眼看着内存涨。这里不是说官方 SDK 不好而是它的定位是协议层工具不是业务运行时。我想要的是一套可控的中间件机制比如在请求进入 handler 之前统一做一次鉴权校验、权限过滤、日志注入这套东西用业务代码包在 SDK 外层也能做但越往后越发现约束太多索性自己实现通信层。MCP 协议本身并不复杂难的是工程化。1.2 手写之前必须吃透的MCP协议底层MCP 协议基于 JSON-RPC 2.0消息格式非常简单每个消息有jsonrpc、id、method、params这几个标准字段。通信方式分两种stdio标准输入输出适合本地进程SSEServer-Sent Events适合远程 HTTP 场景。AI 工具客户端和服务端之间的交互通常用这三种方法组initialize握手、tools/list工具发现、tools/call工具调用。生产级 Server 一般跑远程 HTTP 模式也就是走 SSE。客户端通过 POST 请求发送 JSON-RPC 消息服务端通过 SSE 连接持续推送响应。既然走 HTTP那鉴权、限流、超时这些 Web 领域的老问题就全部进来了。动手写之前我花了一天时间把协议规范里 initialize 的握手流程、请求响应的配对方式、以及通知notification这类不需要响应的消息类型过了一遍这是必须做的前置功课。1.3 我最终选择的语言与项目骨架我选了 Go。理由很简单MCP Server 的核心负载是并发流式传输每个客户端连接需要独立的读写通道服务端还要同时维护大量会话状态。Go 的 goroutine 和 channel 天然适合这种模型写出来的代码不容易出现回调地狱内存控制也可预期。另一个关键因素是部署简单交叉编译出一个二进制扔到服务器就能跑没有 JVM 或 Node 运行时依赖对运维友好。项目目录结构拆成下面这样每个模块的职责一目了然mcp-server/ ├── cmd/server/main.go # 入口负责加载配置和启动 HTTP 服务 ├── internal/ │ ├── transport/ # JSON-RPC 编解码、SSE 连接管理 │ │ ├── sse.go │ │ └── jsonrpc.go │ ├── auth/ # Token 签发、签名校验、ACL 权限 │ │ ├── token.go │ │ └── middleware.go │ ├── session/ # 会话状态机、存储、并发队列 │ │ ├── state.go │ │ ├── store.go │ │ └── queue.go │ ├── tools/ # 工具注册表、执行器 │ │ ├── registry.go │ │ └── executor.go │ └── observability/ # 结构化日志、metrics 埋点 │ ├── logger.go │ └── metrics.go ├── configs/config.yaml └── go.modtransport层负责最底层的消息收发auth是中间件层session处理业务状态tools是你实际对外暴露的能力插件。后面几个模块的代码都在这个骨架里展开。2. 鉴权体系从Token签发到请求放行的完整链路2.1 鉴权不止是验一下Token生产级鉴权要解决的四件事很多人理解鉴权就是客户端带上一个 Token服务端校验一下合法就放行。这在内部 Demo 里没问题但一旦你的 MCP Server 暴露在公网或者同时服务多个团队、多个 AI 客户端鉴权就变成了四件事身份认证你是谁、传输安全数据不被偷看和篡改、防重放别人拿到你的请求复制一份再发一次、权限差分你只能干你该干的事。我之前见过一个系统Token 校验本身没出问题但客户端把 Token 放在了 URL query 参数里日志系统又把完整 URL 打到日志文件Token 直接进了 ELK 索引。这属于典型的验过了Token但没有保护Token。鉴权设计必须把暴露面全部梳理一遍从客户端发起请求到服务端返回响应中间每一跳都要有保护。2.2 Token签发与轮换的闭环设计我的做法是客户端从授权服务换取一个access_token这个 Token 只存活 30 分钟过期后通过refresh_token续签。refresh_token要求走非浏览器环境下发而且每次刷新视为一次新的认证事件写入审计日志方便追溯。Token 本身不存数据库我用 HMAC-SHA256 对关键信息签名服务端无状态验证这样横向扩容时不需要同步 Token 存储。签名内容包含这些字段client_id客户端标识、expires_at过期时间戳、scope授权范围、nonce随机数。校验时先验签名再验时间最后解析 scope。下面是核心签名函数实际项目中我单独放在auth/token.gofunc SignToken(clientID, secret string, scope []string, ttl time.Duration) (string, error) { header : base64.RawURLEncoding.EncodeToString([]byte({alg:HS256,typ:JWT})) expiresAt : time.Now().Add(ttl).Unix() payloadMap : map[string]interface{}{ client_id: clientID, expires_at: expiresAt, scope: scope, nonce: randString(16), } payload, _ : json.Marshal(payloadMap) payloadB64 : base64.RawURLEncoding.EncodeToString(payload) signingInput : header . payloadB64 mac : hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(signingInput)) sig : base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) return signingInput . sig, nil }校验的时候要注意几个细节expires_at的容差窗口我设定为 60 秒允许客户端和服务端之间存在轻微时钟偏差nonce一旦用过就扔进一个带 TTL 的布隆过滤器防止同一签名被重放攻击。轮换策略上refresh_token用一次即作废服务端保存最近 5 个 refresh_token 的 hash用于检测重放。这个闭环跑下来Token 泄露的影响被限制在 30 分钟内而且运维可以通过吊销序列号强制失效。2.3 工具级与资源级ACL让每个客户端只能碰该碰的资源有了身份认证还不够。MCP Server 可能同时服务好几套业务数据团队调用 SQL 查询工具运维团队调用服务器操作工具AI 编程助手调用代码检索工具。如果所有客户端拿到合法 Token 后都能调用全部工具那就是典型的越权风险。我的方案是双级 ACL第一级在工具注册表里声明工具的required_scope第二级在客户端授权数据里记录granted_scopes。中间件在校验身份后提取当前请求的方法名找到对应工具比对两者的交集不为空才放行。工具注册表的结构大概是这样tools: - name: execute_sql required_scope: [db:read, db:write] - name: restart_service required_scope: [ops:admin]请求进来时中间件会做一次快速交集判断func authorize(r *http.Request, clientScopes []string, requiredScopes []string) bool { scopeSet : make(map[string]struct{}, len(clientScopes)) for _, s : range clientScopes { scopeSet[s] struct{}{} } for _, req : range requiredScopes { if _, ok : scopeSet[req]; !ok { return false } } return true }这一步的价值在于即使某个客户端 Token 泄露攻击者能调用的范围也被限制在已授权的工具集内不能模拟管理员操作。2.4 从攻击者视角反查自己的鉴权实现写完善代码后我又花了一天时间扮演攻击者把整个请求流程挨个拷打了一遍结果还真找到几个问题。最危险的一个是我之前也踩过的SSE 的EventSourceAPI 不允许自定义 Header很多客户端只好把 Token 塞到 query 参数里于是 Token 可能出现在代理服务器的 access log、浏览器历史、CDN 日志里——任何一个环节都可能让二次转发者拿到凭证。解法不是要求所有客户端都改用非标方式而是接受现状再缩小暴露窗口给 SSE 连接单独签发一个 60 秒有效的短期自毁 Token仅用于建立事件流真正的业务请求仍走 POST Authorization Header。就算这个短期 Token 进了日志攻击者拿到时大概率已经过期。第二个问题是路径穿越我在 handler 里直接用客户端传入的 toolName 去拼接内部路由虽然 MCP 的 tools/call 方法通过 JSON-RPC 参数传递但如果用到文件系统工具客户端传一个../../etc/passwd就可能造成问题。处理方式是对所有工具名称做白名单映射只允许经过注册表验证的字符串进入执行器。第三个问题是缺少时间戳校验攻击者抓包后可以把旧请求原样重放我在签名里加上了timestamp和nonce双重校验两次校验都在内存里做没有外部存储依赖压测下来对性能影响约 3%。提示任何鉴权系统都应该定期做一次攻击者视角审查把文档假设全部列出来逐个尝试打破它。你往往会有意外收获。3. 流式传输改造SSE协议细节与生产级背压控制3.1 为什么MCP Server默认走SSE而不是WebSocket这里有个常见疑惑既然要流式传输WebSocket 看起来更自然全双工、自己控制协议MCP 为什么不选它核心原因是 SSE 基于 HTTP 单向流协议上手成本极低代理、网关、CDN 几乎不需要额外配置就能支持。而 WebSocket 在复杂网络环境下的长连接保活、代理超时、负载均衡 sticky session 都是额外麻烦。AI 工具生态里SSE 兼容性也最好很多现成的 AI Agent 框架直接内置对 SSE 流的解析。另外MCP 的交互模型本身不是聊天那种双向高频通信客户端主动发起请求服务端不停推送进度和结果SSE 足够用。真正全双工的需求其实很少。所以选 SSE 是用最小的技术成本换取最大的生态兼容性不是能力问题是工程效率问题。3.2 一个可用的SSE流式服务端是怎么实现的SSE 服务端的关键在于两点正确设置响应头、正确刷新流缓冲区。响应头必须包含Content-Type: text/event-stream、Cache-Control: no-cache、Connection: keep-alive。Go 里向客户端写数据后必须手动刷新缓冲区否则数据会卡在服务端内存里。旧代码直接拿http.Flusher断言Go 1.20 之后推荐用http.NewResponseControllerfunc handleSSE(w http.ResponseWriter, r *http.Request, session *Session) { w.Header().Set(Content-Type, text/event-stream) w.Header().Set(Cache-Control, no-cache) w.Header().Set(Connection, keep-alive) rc : http.NewResponseController(w) flusher, err : rc.Flush() if err ! nil { slog.Error(flush creation failed, error, err) return } for { select { case event : -session.eventCh: _, _ fmt.Fprintf(w, event: %s\ndata: %s\n\n, event.Type, event.Data) flusher.Flush() case -r.Context().Done(): session.close() return } } }这段代码的核心逻辑是每个会话一个事件通道handler 循环监听通道有新事件就写入响应并立即刷新。请求的Context被取消客户端断开时handler 退出并清理会话。实际中还要加一个心跳机制SSE 规范里事件流 30 秒没有数据可能被代理中间层断开所以每隔 25 秒发一条注释行以:开头保活。3.3 背压与断线重连生产环境最容易出问题的点SSE 看起来简单但客户端读取慢这个问题就藏得很深。客户端建立连接后服务端不断往 channel 里塞事件如果客户端消费不出channel 缓冲区满了之后服务端要么阻塞要么丢数据。阻塞会导致内存持续增长丢数据会导致 AI 工具收到残缺结果——两种都不可接受。我的做法是给每个会话的事件通道设置队列深度上限默认 1000写入端用非阻塞方式尝试写入队列满了直接丢弃最旧的数据并向上游业务返回backpressure提示。业务侧收到提示后可以选择暂停生成、降级结果或者重试。真正生产环境里一把信号降级比无限堆积更安全。断线重连是另一个重灾区。SSE 协议规定客户端可以用Last-Event-ID头告诉服务端我上次收到的事件 ID 是多少。服务端需要给每个事件递增分配 ID并保留一段时间的已发事件日志客户端重连时把缺失的事件补齐。这里有个工程取舍事件日志不能无限漫游。我实现的是滚动窗口——每个会话最多保留最近 1000 条事件记录超过就丢弃。如果客户端断线超过窗口范围就返回一个明确的session_expired信号让客户端重新初始化而不是默默吞掉部分事件。这个设计在压测中表现稳定。3.4 压测与问题复盘我把这套流式服务跑了一轮压测模拟 1000 个并发连接、每个连接持续接收事件 5 分钟。第一次跑就出现两个问题。第一个是连接数暴增后服务器文件描述符耗尽客户端开始大量连接失败——解决办法是把ulimit -n调大同时限制单 IP 最大连接数。第二个问题更隐蔽压测过程中发现有 goroutine 泄漏排查后发现是心跳 goroutine 在 session 关闭后没有退出信号一直在死循环。修复方案是给心跳加一个独立的stopchannelsession 关闭时同步关闭。注意流式传输的性能瓶颈通常不在协议本身而在无限增长的 goroutine 和无界队列。写任何长期服务的流处理逻辑都要先回答清楚channel 满了怎么办goroutine 怎么退出这两个问题。4. 状态管理会话生命周期、并发隔离与恢复4.1 会话不只是一个ID状态机设计在 MCP 场景里会话管理比普通 Web 会话要复杂得多。一次 AI 工具的调用可能持续几十秒甚至几分钟中间还有用户确认环节会话可能暂停、继续、取消。我把会话定义成显式状态机pending已创建等待初始化、ready空闲可接收请求、streaming正在执行工具调用、paused等待用户输入或工具外部条件、closed已销毁。状态迁移必须走统一接口禁止外部随意修改否则并发环境里会出现状态错乱。type State int const ( StatePending State iota StateReady StateStreaming StatePaused StateClosed ) var allowedTransitions map[State][]State{ StatePending: {StateReady, StateClosed}, StateReady: {StateStreaming, StateClosed}, StateStreaming: {StateReady, StatePaused, StateClosed}, StatePaused: {StateReady, StateClosed}, } func (s *Session) transitionTo(target State) error { for _, allowed : range allowedTransitions[s.state] { if allowed target { s.state target return nil } } return fmt.Errorf(illegal transition: %v - %v, s.state, target) }状态机的作用不只是代码洁癖。它让你能在 session 层做很多策略控制只有ready状态才能接收新的工具调用streaming状态下再收到请求应该排队或拒绝paused状态下收到用户确认消息才流转回ready。AI 工具在对话状态管理上的很多诡异问题比如工具说自己在等用户输入结果项目却一直卡住根本原因就是状态迁移没有严格约束。4.2 状态存储选型内存、Redis还是数据库我做了三套方案的对比测试。纯内存最简单单机性能最强但进程重启一切归零而且多实例部署时不共享会话状态Redis 能跨实例共享会话数据TTL 机制天然适合会话过期清理但每个会话的实时数据比如工具执行进度频繁读写 Redis 会引入额外延迟数据库方案最持久但对高频状态更新场景来说太重而且还会把简单的 key-value 语义搞成关系模型。综合选型后我采用了双写策略热点状态状态机、最近事件队列放内存保证流式处理性能会话元数据创建时间、归属客户端、授权范围写 Redis 并设置 TTL实现跨实例访问和过期回收。如果服务重启内存状态可以从 Redis 里的元数据和最近事件日志恢复。4.3 多租户隔离与并发控制多租户是生产场景绕不开的话题。同一个 MCP Server 服务多个 AI 客户端最担心的就是一个客户端的慢请求拖垮整个服务。我的并发模型是两个维度的同一会话内的请求必须串行不同会话之间并行。原因很简单MCP 的工具调用往往有上下文承接关系后一个调用可能引用前一个调用的输出但不同会话之间没有任何依赖可以充分并行。在 Go 里我用一个 dispatcher 来实现每个 session 维护一个请求队列全局有一组 worker goroutine 从所有队列里取任务执行。每个 worker 执行完当前任务后会检查当前 session 是否还有后续任务有就继续执行没有就回到全局队列领下一个任务。这样可以避免每个 session 占用一个固定 goroutine——如果客户端建立了 1000 个会话但不发请求就不会白白消耗 1000 个 goroutine。type Dispatcher struct { sessionQueues map[string]chan *Request workerCount int } func (d *Dispatcher) Submit(sessionID string, req *Request) { d.sessionQueues[sessionID] - req } func (d *Dispatcher) Run() { for i : 0; i d.workerCount; i { go func() { for sessionID : range d.takeActiveSession() { req : -d.sessionQueues[sessionID] d.execute(req) } }() } }execute函数里再根据工具的声明是 CPU 密集还是 IO 密集决定在 worker goroutine 里跑还是丢到独立执行池。同一会话串行这个前提极大简化了并发正确性的论证同一个 session 的 channel 只有一个 worker 在消费不会有数据竞争。4.4 一次状态丢失事故从Redis过期时间说起上线后第一周就出了一次事故让我对状态管理多了一层敬畏。现象是客户反馈 AI 工具执行到一半突然中断日志里出现 session not found。排查后发现我们把会话的 Redis TTL 设成了 5 分钟结果有一个数据分析工具实际执行了 8 分钟工具执行期间没有任何客户端请求进来会话 TTL 到期被 Redis 自动删除。等工具执行结束想回写进度时服务端发现会话不存在直接丢弃了结果。修复方案是给会话增加活跃续期机制只要会话处于streaming状态每 30 秒自动续一次 TTL客户端侧也通过心跳维持连接活性。另外在存储层加了保护逻辑写状态之前先检查会话是否存在存在才写不存在则返回明确的session_expired让上层决定是重试还是重新初始化。这次事故的教训是任何状态的 TTL 都必须考虑最长业务执行链路不能只从连接活跃度推算。5. 日志与监控生产环境排障的最后一道防线5.1 自定义日志管理别再用fmt.Println打日志工程里第一件要改掉的事就是把所有fmt.Println换成结构化日志。我早期在 MCP Server 里图省事用过打印调试结果线上排障时看到满屏非结构化的字符串字段靠肉眼抠简直灾难。换成 Go 标准库的log/slog后每行日志变成 JSON 格式字段搜索、聚合、告警全都自动化了。但我没有直接用默认的 slog而是封装了一层自定义 Handler做三件事自动注入 traceId每个请求进来时生成、根据日志级别选择输出目标INFO 输出到 stdoutERROR 输出到 stderr、统一脱敏任何包含 authorization、refresh_token 的字段一律打星号。type redactHandler struct { inner slog.Handler } func (h *redactHandler) Handle(ctx context.Context, r slog.Record) error { r.Add(trace_id, ctx.Value(traceKey)) r.Add(session_id, ctx.Value(sessionKey)) return h.inner.Handle(ctx, r) }除了请求日志我单独做了一个审计日志通道记录所有鉴权成功、失败、Token 刷新、权限拒绝事件输出到独立文件。这个文件不参与普通日志轮转保留周期 180 天。合规层面审计日志的意义在于出了安全事件后你能精确还原谁、在什么时候、调用了什么、返回了什么。5.2 关键埋点与告警指标MCP Server 和别的 Web 服务一样必须回答四个问题请求量多少QPS、请求多快P99 延迟、失败率多少、当前有多少活跃连接。我加了一组轻量 metrics 计数器不引入完整的 Prometheus 依赖用一个带原子操作的结构体维护定期暴露/metrics接口。生产环境里我盯得最多的是三个指标stream_queue_depth事件通道队列深度超过 500 说明有客户端消费不过来、active_sessions活跃会话数异常暴涨可能是有刷接口或者工具卡住不退出、p99_latency超过 2 秒就要排查是哪个工具调用拖慢了链路。type Metrics struct { requestsTotal atomic.Int64 requestsErrorTotal atomic.Int64 activeSessions atomic.Int64 streamQueueDepth atomic.Int64 }告警阈值需要根据真实压测来定不要迷信默认值。我一开始把 P99 阈值设成 500ms结果上线第一天就疯狂告警因为某些数据工具外呼第三方 API 天然需要 3 秒以上。后来我把阈值改成按工具维度分开配置数据类工具 5 秒操作类工具 1 秒才算安静下来。这里要注意不要为了凑监控指标引入太多复杂性核心四个指标覆盖住出事能定位就够了。5.3 一次线上排障过程复盘有一次收到用户反馈AI 工具总是断连重新连上之后之前的任务又不见了。我先去查日志用 traceId 把一次请求的完整生命周期拉出来。发现用户的任务在streaming状态运行了 40 秒客户端第 41 秒发了新的请求但日志显示这个请求没有进入 dispatcher 的调度直接被 429 拒绝了。再往下看 metricsstream_queue_depth在任务执行期间一度涨到 1200触发了保护性丢弃。根源是一个很意外的点这个客户端的 AI 工具在流式读取的同时内部还跑了一个定时器反复发tools/list查询导致同一个会话的事件通道被打满真正重要的任务事件反而被挤掉。修复分两步走首先在服务端把tools/list这类元数据查询和tools/call这类业务执行分到不同的请求队列防止元数据请求塞满业务通道其次在客户端侧文档里明确建议减少无意义的轮询。经过这次复盘我对背压机制的理解深了一层它不是简单的慢了就丢而是要确保优先级高的事件不因低优先级事件而被牺牲。提示MCP Server 的日志字段里trace_id、session_id、client_id这三个必须贯穿所有日志。缺任何一个都会让分布式排查变成猜谜游戏。写在最后手写一遍比看一遍文档强十倍这段手写 MCP Server 的经历给我的最大收获不是我能不看官方 SDK 自己实现协议而是对协议背后的工程决策有了真正的理解。为什么默认走 SSE因为生态兼容比全双工能力更重要为什么鉴权必须做短期 Token ACL 双层设计因为暴露窗口决定了防风险能力为什么状态管理要引入显式状态机因为并发的世界里没有约束的修改就是事故温床。对你来说如果有一个现成 SDK 能满足需求不必非要重写。但如果你的场景里出现了SDK 限制太多需要对协议层做定制排障时无从下手这类信号建议你也试着亲手实现一遍最小闭环——传输层、鉴权层、状态层、可观测层每个模块都会教给你文档里学不到的东西。后续如果要做插件化工具注册、多协议接入这个骨架还预留了清晰的扩展边界可以在现有 transport 层上继续生长。